diff --git a/Cargo.lock b/Cargo.lock index f77b5dbff72c..56f80383051a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 7abfdd37f2e8..de4dbba76ccb 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -53,3 +53,4 @@ path = "src/bin/generate_schema.rs" [dev-dependencies] tower = "0.5" async-trait = "0.1" +tempfile = "3.15.0" diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index c2012ef4a8bc..f3745c410f20 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -393,7 +393,9 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::create_recipe, super::routes::recipe::encode_recipe, super::routes::recipe::decode_recipe, - super::routes::recipe::scan_recipe + super::routes::recipe::scan_recipe, + super::routes::recipe::list_recipes, + super::routes::recipe::delete_recipe, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -464,6 +466,9 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::DecodeRecipeResponse, super::routes::recipe::ScanRecipeRequest, super::routes::recipe::ScanRecipeResponse, + super::routes::recipe::RecipeManifestResponse, + super::routes::recipe::ListRecipeResponse, + super::routes::recipe::DeleteRecipeRequest, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index dfc96b8c1bbb..bc86bbd4ef08 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -6,6 +6,7 @@ pub mod context; pub mod extension; pub mod health; pub mod recipe; +pub mod recipe_utils; pub mod reply; pub mod schedule; pub mod session; diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index ad463aefed4d..93c8620de8f0 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -1,12 +1,19 @@ +use std::collections::HashMap; +use std::fs; use std::sync::Arc; +use axum::routing::get; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use goose::conversation::{message::Message, Conversation}; use goose::recipe::Recipe; use goose::recipe_deeplink; + +use http::HeaderMap; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::routes::recipe_utils::get_all_recipes_manifests; +use crate::routes::utils::verify_secret_key; use crate::state::AppState; #[derive(Debug, Deserialize, ToSchema)] @@ -66,6 +73,27 @@ pub struct ScanRecipeResponse { has_security_warnings: bool, } +#[derive(Debug, Serialize, ToSchema)] +pub struct RecipeManifestResponse { + name: String, + #[serde(rename = "isGlobal")] + is_global: bool, + recipe: Recipe, + #[serde(rename = "lastModified")] + last_modified: String, + id: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct DeleteRecipeRequest { + id: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ListRecipeResponse { + recipe_manifest_responses: Vec, +} + #[utoipa::path( post, path = "/recipes/create", @@ -209,12 +237,87 @@ async fn scan_recipe( })) } +#[utoipa::path( + get, + path = "/recipes/list", + responses( + (status = 200, description = "Get recipe list successfully", body = ListRecipeResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +async fn list_recipes( + State(state): State>, + headers: HeaderMap, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap(); + let mut recipe_file_hash_map = HashMap::new(); + let recipe_manifest_responses = recipe_manifest_with_paths + .iter() + .map(|recipe_manifest_with_path| { + let id = &recipe_manifest_with_path.id; + let file_path = recipe_manifest_with_path.file_path.clone(); + recipe_file_hash_map.insert(id.clone(), file_path); + RecipeManifestResponse { + name: recipe_manifest_with_path.name.clone(), + is_global: recipe_manifest_with_path.is_global, + recipe: recipe_manifest_with_path.recipe.clone(), + id: id.clone(), + last_modified: recipe_manifest_with_path.last_modified.clone(), + } + }) + .collect::>(); + state.set_recipe_file_hash_map(recipe_file_hash_map).await; + + Ok(Json(ListRecipeResponse { + recipe_manifest_responses, + })) +} + +#[utoipa::path( + post, + path = "/recipes/delete", + request_body = DeleteRecipeRequest, + responses( + (status = 204, description = "Recipe deleted successfully"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Recipe not found"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +async fn delete_recipe( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> StatusCode { + if verify_secret_key(&headers, &state).is_err() { + return StatusCode::UNAUTHORIZED; + } + let recipe_file_hash_map = state.recipe_file_hash_map.lock().await; + let file_path = match recipe_file_hash_map.get(&request.id) { + Some(path) => path, + None => return StatusCode::NOT_FOUND, + }; + + if fs::remove_file(file_path).is_err() { + return StatusCode::INTERNAL_SERVER_ERROR; + } + + StatusCode::NO_CONTENT +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) .route("/recipes/encode", post(encode_recipe)) .route("/recipes/decode", post(decode_recipe)) .route("/recipes/scan", post(scan_recipe)) + .route("/recipes/list", get(list_recipes)) + .route("/recipes/delete", post(delete_recipe)) .with_state(state) } diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs new file mode 100644 index 000000000000..8fba2fea6853 --- /dev/null +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -0,0 +1,130 @@ +use std::fs; +use std::hash::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; + +use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; + +use goose::config::APP_STRATEGY; +use goose::recipe::read_recipe_file_content::read_recipe_file; +use goose::recipe::Recipe; + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub struct RecipeManifestWithPath { + pub id: String, + pub name: String, + pub is_global: bool, + pub recipe: Recipe, + pub file_path: PathBuf, + pub last_modified: String, +} + +fn short_id_from_path(path: &str) -> String { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + let h = hasher.finish(); + format!("{:016x}", h) +} + +fn load_recipes_from_path(path: &PathBuf) -> Result> { + let mut recipe_manifests_with_path = Vec::new(); + if path.exists() { + for entry in fs::read_dir(path)? { + let path = entry?.path(); + if path.extension() == Some("yaml".as_ref()) { + let Ok(recipe_file) = read_recipe_file(path.clone()) else { + continue; + }; + let Ok(recipe) = Recipe::from_content(&recipe_file.content) else { + continue; + }; + let Ok(recipe_metadata) = RecipeManifestMetadata::from_yaml_file(&path) else { + continue; + }; + let Ok(last_modified) = fs::metadata(path.clone()).map(|m| { + chrono::DateTime::::from(m.modified().unwrap()).to_rfc3339() + }) else { + continue; + }; + + let manifest_with_path = RecipeManifestWithPath { + id: short_id_from_path(recipe_file.file_path.to_string_lossy().as_ref()), + name: recipe_metadata.name, + is_global: recipe_metadata.is_global, + recipe, + file_path: recipe_file.file_path, + last_modified, + }; + recipe_manifests_with_path.push(manifest_with_path); + } + } + } + Ok(recipe_manifests_with_path) +} + +pub fn get_all_recipes_manifests() -> Result> { + let current_dir = std::env::current_dir()?; + let local_recipe_path = current_dir.join(".goose/recipes"); + + let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .config_dir() + .join("recipes"); + + let mut recipe_manifests_with_path = Vec::new(); + + recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path)?); + recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path)?); + recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); + + Ok(recipe_manifests_with_path) +} + +// this is a temporary struct to deserilize the UI recipe files. should not be used for other purposes. +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +struct RecipeManifestMetadata { + pub name: String, + #[serde(rename = "isGlobal")] + pub is_global: bool, +} + +impl RecipeManifestMetadata { + pub fn from_yaml_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path.display(), e))?; + let metadata = serde_yaml::from_str::(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?; + Ok(metadata) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_from_yaml_file_success() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test_recipe.yaml"); + + let yaml_content = r#" +name: "Test Recipe" +isGlobal: true +recipe: recipe_content +"#; + + fs::write(&file_path, yaml_content).unwrap(); + + let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap(); + + assert_eq!(result.name, "Test Recipe"); + assert_eq!(result.is_global, true); + } +} diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 720912b0c4f8..e72363f4bea0 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -1,5 +1,7 @@ use goose::agents::Agent; use goose::scheduler_trait::SchedulerTrait; +use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -10,6 +12,7 @@ pub struct AppState { agent: Option, pub secret_key: String, pub scheduler: Arc>>>, + pub recipe_file_hash_map: Arc>>, } impl AppState { @@ -18,6 +21,7 @@ impl AppState { agent: Some(agent.clone()), secret_key, scheduler: Arc::new(Mutex::new(None)), + recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), }) } @@ -39,4 +43,9 @@ impl AppState { .clone() .ok_or_else(|| anyhow::anyhow!("Scheduler not initialized")) } + + pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap) { + let mut map = self.recipe_file_hash_map.lock().await; + *map = hash_map; + } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 4ee811a1ed71..39dd635673cb 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -862,6 +862,38 @@ } } }, + "/recipes/delete": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "delete_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Recipe deleted successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Recipe not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/recipes/encode": { "post": { "tags": [ @@ -895,6 +927,32 @@ } } }, + "/recipes/list": { + "get": { + "tags": [ + "Recipe Management" + ], + "operationId": "list_recipes", + "responses": { + "200": { + "description": "Get recipe list successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRecipeResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/recipes/scan": { "post": { "tags": [ @@ -1717,6 +1775,17 @@ } } }, + "DeleteRecipeRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, "EmbeddedResource": { "type": "object", "required": [ @@ -2257,6 +2326,20 @@ } } }, + "ListRecipeResponse": { + "type": "object", + "required": [ + "recipe_manifest_responses" + ], + "properties": { + "recipe_manifest_responses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipeManifestResponse" + } + } + } + }, "ListSchedulesResponse": { "type": "object", "required": [ @@ -2804,6 +2887,33 @@ } } }, + "RecipeManifestResponse": { + "type": "object", + "required": [ + "name", + "isGlobal", + "recipe", + "lastModified", + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "isGlobal": { + "type": "boolean" + }, + "lastModified": { + "type": "string" + }, + "name": { + "type": "string" + }, + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "RecipeParameter": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index a36b1ce97c9f..208017617c7f 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -259,6 +259,17 @@ export const decodeRecipe = (options: Opti }); }; +export const deleteRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/delete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const encodeRecipe = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/recipes/encode', @@ -270,6 +281,13 @@ export const encodeRecipe = (options: Opti }); }; +export const listRecipes = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/recipes/list', + ...options + }); +}; + export const scanRecipe = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/recipes/scan', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 9d2e4621727f..4edbb65c77b0 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -135,6 +135,10 @@ export type DecodeRecipeResponse = { recipe: Recipe; }; +export type DeleteRecipeRequest = { + id: string; +}; + export type EmbeddedResource = { annotations?: Annotations | { [key: string]: unknown; @@ -332,6 +336,10 @@ export type KillJobResponse = { message: string; }; +export type ListRecipeResponse = { + recipe_manifest_responses: Array; +}; + export type ListSchedulesResponse = { jobs: Array; }; @@ -542,6 +550,14 @@ export type Recipe = { version?: string; }; +export type RecipeManifestResponse = { + id: string; + isGlobal: boolean; + lastModified: string; + name: string; + recipe: Recipe; +}; + export type RecipeParameter = { default?: string | null; description: string; @@ -1529,6 +1545,37 @@ export type DecodeRecipeResponses = { export type DecodeRecipeResponse2 = DecodeRecipeResponses[keyof DecodeRecipeResponses]; +export type DeleteRecipeData = { + body: DeleteRecipeRequest; + path?: never; + query?: never; + url: '/recipes/delete'; +}; + +export type DeleteRecipeErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Recipe not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type DeleteRecipeResponses = { + /** + * Recipe deleted successfully + */ + 204: void; +}; + +export type DeleteRecipeResponse = DeleteRecipeResponses[keyof DeleteRecipeResponses]; + export type EncodeRecipeData = { body: EncodeRecipeRequest; path?: never; @@ -1552,6 +1599,33 @@ export type EncodeRecipeResponses = { export type EncodeRecipeResponse2 = EncodeRecipeResponses[keyof EncodeRecipeResponses]; +export type ListRecipesData = { + body?: never; + path?: never; + query?: never; + url: '/recipes/list'; +}; + +export type ListRecipesErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ListRecipesResponses = { + /** + * Get recipe list successfully + */ + 200: ListRecipeResponse; +}; + +export type ListRecipesResponse = ListRecipesResponses[keyof ListRecipesResponses]; + export type ScanRecipeData = { body: ScanRecipeRequest; path?: never; diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 37bdca8f4ed0..1e88121a758a 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -227,7 +227,7 @@ export default function ProgressiveMessageList({ toolCallNotifications, isStreamingMessage, onMessageUpdate, - hasCompactionMarker + hasCompactionMarker, ]); return ( diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index 674b350cde85..553914711b4a 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -1,21 +1,11 @@ import { useState, useEffect } from 'react'; import { listSavedRecipes, - archiveRecipe, - SavedRecipe, saveRecipe, generateRecipeFilename, + convertToLocaleDateString, } from '../recipe/recipeStorage'; -import { - FileText, - Trash2, - Bot, - Calendar, - Globe, - Folder, - AlertCircle, - Download, -} from 'lucide-react'; +import { FileText, Trash2, Bot, Calendar, AlertCircle, Download } from 'lucide-react'; import { ScrollArea } from './ui/scroll-area'; import { Card } from './ui/card'; import { Button } from './ui/button'; @@ -24,6 +14,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import { Recipe, decodeRecipe, generateDeepLink } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; +import { deleteRecipe, RecipeManifestResponse } from '../api'; interface RecipesViewProps { onLoadRecipe?: (recipe: Recipe) => void; @@ -31,11 +22,11 @@ interface RecipesViewProps { // @ts-expect-error until we make onLoadRecipe work for loading recipes in the same window export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { - const [savedRecipes, setSavedRecipes] = useState([]); + const [savedRecipes, setSavedRecipes] = useState([]); const [loading, setLoading] = useState(true); const [showSkeleton, setShowSkeleton] = useState(true); const [error, setError] = useState(null); - const [selectedRecipe, setSelectedRecipe] = useState(null); + const [selectedRecipe, setSelectedRecipe] = useState(null); const [showPreview, setShowPreview] = useState(false); const [showContent, setShowContent] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); @@ -99,8 +90,8 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { setShowSkeleton(true); setShowContent(false); setError(null); - const recipes = await listSavedRecipes(); - setSavedRecipes(recipes); + const recipeManifestResponses = await listSavedRecipes(); + setSavedRecipes(recipeManifestResponses); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load recipes'); console.error('Failed to load saved recipes:', err); @@ -109,7 +100,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleLoadRecipe = async (savedRecipe: SavedRecipe) => { + const handleLoadRecipe = async (recipe: Recipe) => { try { // onLoadRecipe is not working for loading recipes. It looks correct // but the instructions are not flowing through to the server. @@ -125,7 +116,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { undefined, // dir undefined, // version undefined, // resumeSessionId - savedRecipe.recipe, // recipe config + recipe, // recipe config undefined // view type ); // } @@ -135,15 +126,15 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleDeleteRecipe = async (savedRecipe: SavedRecipe) => { + const handleDeleteRecipe = async (recipeManifest: RecipeManifestResponse) => { // TODO: Use Electron's dialog API for confirmation const result = await window.electron.showMessageBox({ type: 'warning', buttons: ['Cancel', 'Delete'], defaultId: 0, title: 'Delete Recipe', - message: `Are you sure you want to delete "${savedRecipe.name}"?`, - detail: 'Deleted recipes can be restored later.', + message: `Are you sure you want to delete "${recipeManifest.name}"?`, + detail: 'Recipe file will be deleted.', }); if (result.response !== 1) { @@ -151,22 +142,25 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } try { - await archiveRecipe(savedRecipe.name, savedRecipe.isGlobal); - // Reload the recipes list + await deleteRecipe({ body: { id: recipeManifest.id } }); await loadSavedRecipes(); + toastSuccess({ + title: recipeManifest.name, + msg: 'Recipe deleted successfully', + }); } catch (err) { - console.error('Failed to archive recipe:', err); - setError(err instanceof Error ? err.message : 'Failed to archive recipe'); + console.error('Failed to delete recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to delete recipe'); } }; - const handlePreviewRecipe = async (savedRecipe: SavedRecipe) => { - setSelectedRecipe(savedRecipe); + const handlePreviewRecipe = async (recipeManifest: RecipeManifestResponse) => { + setSelectedRecipe(recipeManifest); setShowPreview(true); // Generate deeplink for preview try { - const deeplink = await generateDeepLink(savedRecipe.recipe); + const deeplink = await generateDeepLink(recipeManifest.recipe); setPreviewDeeplink(deeplink); } catch (error) { console.error('Failed to generate deeplink for preview:', error); @@ -372,90 +366,65 @@ Parameters you can use: } }; - // Render a recipe item with error handling - const RecipeItem = ({ savedRecipe }: { savedRecipe: SavedRecipe }) => { - try { - return ( - -
-
-
-

{savedRecipe.recipe.title}

- {savedRecipe.isGlobal ? ( - - ) : ( - - )} -
-

- {savedRecipe.recipe.description} -

-
- - {savedRecipe.lastModified.toLocaleDateString()} -
-
- -
- - - -
+ // Render a recipe item + const RecipeItem = ({ + recipeManifestResponse, + recipeManifestResponse: { recipe, lastModified }, + }: { + recipeManifestResponse: RecipeManifestResponse; + }) => ( + +
+
+
+

{recipe.title}

- - ); - } catch (error) { - // Error row showing failed to read file with filename and error details - return ( - -
-
-
- -

- Failed to read file: {savedRecipe.filename} -

-
-

- {error instanceof Error ? error.message : 'Unknown error'} -

-
+

{recipe.description}

+
+ + {convertToLocaleDateString(lastModified)}
- - ); - } - }; +
+ +
+ + + +
+
+ + ); // Render skeleton loader for recipe items const RecipeSkeleton = () => ( @@ -515,10 +484,10 @@ Parameters you can use: return (
- {savedRecipes.map((savedRecipe) => ( + {savedRecipes.map((recipeManifestResponse: RecipeManifestResponse) => ( ))}
@@ -584,9 +553,6 @@ Parameters you can use:

{selectedRecipe.recipe.title}

-

- {selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'} -

)} - - {selectedRecipe.recipe.goosehints && ( -
-

Goose Hints

-
-
-                      {selectedRecipe.recipe.goosehints}
-                    
-
-
- )} - {selectedRecipe.recipe.context && selectedRecipe.recipe.context.length > 0 && (

Context

@@ -949,30 +903,6 @@ Parameters you can use:
)} - {selectedRecipe.recipe.profile && ( -
-

Profile

-
- - {selectedRecipe.recipe.profile} - -
-
- )} - - {selectedRecipe.recipe.mcps && ( -
-

- Max Completion Tokens per Second -

-
- - {selectedRecipe.recipe.mcps} - -
-
- )} - {selectedRecipe.recipe.author && (

Author

@@ -1001,7 +931,7 @@ Parameters you can use:
); - + const compactButton = screen.getByText('Compact now'); fireEvent.click(compactButton); - + expect(mockOnCompact).toHaveBeenCalledTimes(1); expect(mockParentClick).not.toHaveBeenCalled(); }); @@ -240,7 +242,7 @@ describe('AlertBox', () => { }; render(); - + expect(screen.queryByText('Compact now')).not.toBeInTheDocument(); }); @@ -253,7 +255,7 @@ describe('AlertBox', () => { }; render(); - + expect(screen.queryByText('Compact now')).not.toBeInTheDocument(); }); }); @@ -272,7 +274,7 @@ describe('AlertBox', () => { }; render(); - + expect(screen.getByText('75')).toBeInTheDocument(); expect(screen.getByText('75%')).toBeInTheDocument(); expect(screen.getByText('100')).toBeInTheDocument(); @@ -286,9 +288,14 @@ describe('AlertBox', () => { }; render(); - + // Use a function matcher to handle the whitespace-pre-line rendering - expect(screen.getByText((content) => content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3'))).toBeInTheDocument(); + expect( + screen.getByText( + (content) => + content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3') + ) + ).toBeInTheDocument(); }); }); @@ -300,7 +307,7 @@ describe('AlertBox', () => { }; const { container } = render(); - + // Should still render the alert container const alertElement = container.querySelector('.flex.flex-col.gap-2'); expect(alertElement).toBeInTheDocument(); @@ -317,7 +324,7 @@ describe('AlertBox', () => { }; render(); - + expect(screen.getByText('10')).toBeInTheDocument(); expect(screen.getByText('0')).toBeInTheDocument(); // Progress percentage would be Infinity, but it should still render diff --git a/ui/desktop/src/components/alerts/__tests__/useAlerts.test.tsx b/ui/desktop/src/components/alerts/__tests__/useAlerts.test.tsx index 30f2cb235251..c145a6600f8d 100644 --- a/ui/desktop/src/components/alerts/__tests__/useAlerts.test.tsx +++ b/ui/desktop/src/components/alerts/__tests__/useAlerts.test.tsx @@ -11,7 +11,7 @@ describe('useAlerts', () => { describe('Initial State', () => { it('should start with empty alerts array', () => { const { result } = renderHook(() => useAlerts()); - + expect(result.current.alerts).toEqual([]); expect(typeof result.current.addAlert).toBe('function'); expect(typeof result.current.clearAlerts).toBe('function'); @@ -21,33 +21,33 @@ describe('useAlerts', () => { describe('Adding Alerts', () => { it('should add a single alert', () => { const { result } = renderHook(() => useAlerts()); - + const newAlert = { type: AlertType.Info, message: 'Test alert', }; - + act(() => { result.current.addAlert(newAlert); }); - + expect(result.current.alerts).toHaveLength(1); expect(result.current.alerts[0]).toMatchObject(newAlert); }); it('should add multiple alerts', () => { const { result } = renderHook(() => useAlerts()); - + const alert1 = { type: AlertType.Info, message: 'First alert' }; const alert2 = { type: AlertType.Warning, message: 'Second alert' }; const alert3 = { type: AlertType.Error, message: 'Third alert' }; - + act(() => { result.current.addAlert(alert1); result.current.addAlert(alert2); result.current.addAlert(alert3); }); - + expect(result.current.alerts).toHaveLength(3); expect(result.current.alerts[0]).toMatchObject(alert1); expect(result.current.alerts[1]).toMatchObject(alert2); @@ -56,7 +56,7 @@ describe('useAlerts', () => { it('should add alerts with all optional properties', () => { const { result } = renderHook(() => useAlerts()); - + const complexAlert = { type: AlertType.Info, message: 'Complex alert', @@ -66,11 +66,11 @@ describe('useAlerts', () => { compactIcon: Icon, autoShow: true, }; - + act(() => { result.current.addAlert(complexAlert); }); - + expect(result.current.alerts).toHaveLength(1); expect(result.current.alerts[0]).toMatchObject(complexAlert); }); @@ -79,35 +79,35 @@ describe('useAlerts', () => { describe('Clearing Alerts', () => { it('should clear all alerts', () => { const { result } = renderHook(() => useAlerts()); - + // Add some alerts first act(() => { result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' }); result.current.addAlert({ type: AlertType.Warning, message: 'Alert 2' }); result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' }); }); - + expect(result.current.alerts).toHaveLength(3); - + // Clear all alerts act(() => { result.current.clearAlerts(); }); - + expect(result.current.alerts).toHaveLength(0); expect(result.current.alerts).toEqual([]); }); it('should handle clearing when no alerts exist', () => { const { result } = renderHook(() => useAlerts()); - + expect(result.current.alerts).toHaveLength(0); - + // Should not throw error act(() => { result.current.clearAlerts(); }); - + expect(result.current.alerts).toHaveLength(0); }); }); @@ -115,7 +115,7 @@ describe('useAlerts', () => { describe('Alert Management Patterns', () => { it('should handle rapid add and clear operations', () => { const { result } = renderHook(() => useAlerts()); - + // Rapid operations act(() => { result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' }); @@ -123,25 +123,25 @@ describe('useAlerts', () => { result.current.clearAlerts(); result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' }); }); - + expect(result.current.alerts).toHaveLength(1); expect(result.current.alerts[0].message).toBe('Alert 3'); }); it('should maintain alert order', () => { const { result } = renderHook(() => useAlerts()); - + const alerts = [ { type: AlertType.Info, message: 'First' }, { type: AlertType.Warning, message: 'Second' }, { type: AlertType.Error, message: 'Third' }, { type: AlertType.Info, message: 'Fourth' }, ]; - + act(() => { - alerts.forEach(alert => result.current.addAlert(alert)); + alerts.forEach((alert) => result.current.addAlert(alert)); }); - + expect(result.current.alerts).toHaveLength(4); alerts.forEach((alert, index) => { expect(result.current.alerts[index].message).toBe(alert.message); @@ -150,18 +150,18 @@ describe('useAlerts', () => { it('should handle duplicate alerts', () => { const { result } = renderHook(() => useAlerts()); - + const duplicateAlert = { type: AlertType.Info, message: 'Duplicate alert' }; - + act(() => { result.current.addAlert(duplicateAlert); result.current.addAlert(duplicateAlert); result.current.addAlert(duplicateAlert); }); - + // Should allow duplicates expect(result.current.alerts).toHaveLength(3); - result.current.alerts.forEach(alert => { + result.current.alerts.forEach((alert) => { expect(alert.message).toBe('Duplicate alert'); }); }); @@ -170,17 +170,17 @@ describe('useAlerts', () => { describe('Alert Types', () => { it('should handle all alert types', () => { const { result } = renderHook(() => useAlerts()); - + const alertTypes = [ { type: AlertType.Info, message: 'Info alert' }, { type: AlertType.Warning, message: 'Warning alert' }, { type: AlertType.Error, message: 'Error alert' }, ]; - + act(() => { - alertTypes.forEach(alert => result.current.addAlert(alert)); + alertTypes.forEach((alert) => result.current.addAlert(alert)); }); - + expect(result.current.alerts).toHaveLength(3); expect(result.current.alerts[0].type).toBe(AlertType.Info); expect(result.current.alerts[1].type).toBe(AlertType.Warning); @@ -191,23 +191,23 @@ describe('useAlerts', () => { describe('Progress Alerts', () => { it('should handle alerts with progress', () => { const { result } = renderHook(() => useAlerts()); - + const progressAlert = { type: AlertType.Info, message: 'Loading...', progress: { current: 25, total: 100 }, }; - + act(() => { result.current.addAlert(progressAlert); }); - + expect(result.current.alerts[0].progress).toEqual({ current: 25, total: 100 }); }); it('should handle progress updates by replacing alerts', () => { const { result } = renderHook(() => useAlerts()); - + // Add initial progress alert act(() => { result.current.addAlert({ @@ -216,9 +216,9 @@ describe('useAlerts', () => { progress: { current: 25, total: 100 }, }); }); - + expect(result.current.alerts[0].progress?.current).toBe(25); - + // Clear and add updated progress act(() => { result.current.clearAlerts(); @@ -228,7 +228,7 @@ describe('useAlerts', () => { progress: { current: 75, total: 100 }, }); }); - + expect(result.current.alerts).toHaveLength(1); expect(result.current.alerts[0].progress?.current).toBe(75); }); @@ -237,7 +237,7 @@ describe('useAlerts', () => { describe('Compact Button Alerts', () => { it('should handle alerts with compact functionality', () => { const { result } = renderHook(() => useAlerts()); - + const mockOnCompact = vi.fn(); const compactAlert = { type: AlertType.Info, @@ -246,11 +246,11 @@ describe('useAlerts', () => { onCompact: mockOnCompact, compactIcon: 📦, }; - + act(() => { result.current.addAlert(compactAlert); }); - + const alert = result.current.alerts[0]; expect(alert.showCompactButton).toBe(true); expect(alert.onCompact).toBe(mockOnCompact); @@ -261,32 +261,32 @@ describe('useAlerts', () => { describe('Auto-show Alerts', () => { it('should handle autoShow property', () => { const { result } = renderHook(() => useAlerts()); - + const autoShowAlert = { type: AlertType.Error, message: 'Critical error', autoShow: true, }; - + act(() => { result.current.addAlert(autoShowAlert); }); - + expect(result.current.alerts[0].autoShow).toBe(true); }); it('should handle alerts without autoShow property', () => { const { result } = renderHook(() => useAlerts()); - + const regularAlert = { type: AlertType.Info, message: 'Regular alert', }; - + act(() => { result.current.addAlert(regularAlert); }); - + expect(result.current.alerts[0].autoShow).toBeUndefined(); }); }); @@ -294,45 +294,45 @@ describe('useAlerts', () => { describe('Edge Cases', () => { it('should handle empty message', () => { const { result } = renderHook(() => useAlerts()); - + act(() => { result.current.addAlert({ type: AlertType.Info, message: '', }); }); - + expect(result.current.alerts).toHaveLength(1); expect(result.current.alerts[0].message).toBe(''); }); it('should handle very long messages', () => { const { result } = renderHook(() => useAlerts()); - + const longMessage = 'A'.repeat(1000); - + act(() => { result.current.addAlert({ type: AlertType.Info, message: longMessage, }); }); - + expect(result.current.alerts[0].message).toBe(longMessage); }); it('should handle special characters in messages', () => { const { result } = renderHook(() => useAlerts()); - + const specialMessage = '🚨 Alert with émojis and spëcial chars! @#$%^&*()'; - + act(() => { result.current.addAlert({ type: AlertType.Warning, message: specialMessage, }); }); - + expect(result.current.alerts[0].message).toBe(specialMessage); }); }); diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index d3c2ef1b25e5..01af1b88642c 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -158,449 +158,457 @@ interface SessionListViewProps { selectedSessionId?: string | null; } -const SessionListView: React.FC = React.memo(({ onSelectSession, selectedSessionId }) => { - const [sessions, setSessions] = useState([]); - const [filteredSessions, setFilteredSessions] = useState([]); - const [dateGroups, setDateGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showSkeleton, setShowSkeleton] = useState(true); - const [showContent, setShowContent] = useState(false); - const [isInitialLoad, setIsInitialLoad] = useState(true); - const [error, setError] = useState(null); - const [searchResults, setSearchResults] = useState<{ - count: number; - currentIndex: number; - } | null>(null); - - // Edit modal state - const [showEditModal, setShowEditModal] = useState(false); - const [editingSession, setEditingSession] = useState(null); - - // Search state for debouncing - const [searchTerm, setSearchTerm] = useState(''); - const [caseSensitive, setCaseSensitive] = useState(false); - const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce - - const containerRef = useRef(null); - - // Track session to element ref - const sessionRefs = useRef>({}); - const setSessionRefs = (itemId: string, element: HTMLDivElement | null) => { - if (element) { - sessionRefs.current[itemId] = element; - } else { - delete sessionRefs.current[itemId]; - } - }; - - const loadSessions = useCallback(async () => { - setIsLoading(true); - setShowSkeleton(true); - setShowContent(false); - setError(null); - try { - const sessions = await fetchSessions(); - // Use startTransition to make state updates non-blocking - startTransition(() => { - setSessions(sessions); - setFilteredSessions(sessions); - }); - } catch (err) { - console.error('Failed to load sessions:', err); - setError('Failed to load sessions. Please try again later.'); - setSessions([]); - setFilteredSessions([]); - } finally { - setIsLoading(false); - } - }, []); +const SessionListView: React.FC = React.memo( + ({ onSelectSession, selectedSessionId }) => { + const [sessions, setSessions] = useState([]); + const [filteredSessions, setFilteredSessions] = useState([]); + const [dateGroups, setDateGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showSkeleton, setShowSkeleton] = useState(true); + const [showContent, setShowContent] = useState(false); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const [error, setError] = useState(null); + const [searchResults, setSearchResults] = useState<{ + count: number; + currentIndex: number; + } | null>(null); + + // Edit modal state + const [showEditModal, setShowEditModal] = useState(false); + const [editingSession, setEditingSession] = useState(null); + + // Search state for debouncing + const [searchTerm, setSearchTerm] = useState(''); + const [caseSensitive, setCaseSensitive] = useState(false); + const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce + + const containerRef = useRef(null); + + // Track session to element ref + const sessionRefs = useRef>({}); + const setSessionRefs = (itemId: string, element: HTMLDivElement | null) => { + if (element) { + sessionRefs.current[itemId] = element; + } else { + delete sessionRefs.current[itemId]; + } + }; - useEffect(() => { - loadSessions(); - }, [loadSessions]); + const loadSessions = useCallback(async () => { + setIsLoading(true); + setShowSkeleton(true); + setShowContent(false); + setError(null); + try { + const sessions = await fetchSessions(); + // Use startTransition to make state updates non-blocking + startTransition(() => { + setSessions(sessions); + setFilteredSessions(sessions); + }); + } catch (err) { + console.error('Failed to load sessions:', err); + setError('Failed to load sessions. Please try again later.'); + setSessions([]); + setFilteredSessions([]); + } finally { + setIsLoading(false); + } + }, []); - // Timing logic to prevent flicker between skeleton and content on initial load - useEffect(() => { - if (!isLoading && showSkeleton) { - setShowSkeleton(false); - // Use startTransition for non-blocking content show - startTransition(() => { - setTimeout(() => { - setShowContent(true); - if (isInitialLoad) { - setIsInitialLoad(false); - } - }, 10); - }); - } - return () => void 0; - }, [isLoading, showSkeleton, isInitialLoad]); - - // Memoize date groups calculation to prevent unnecessary recalculations - const memoizedDateGroups = useMemo(() => { - if (filteredSessions.length > 0) { - return groupSessionsByDate(filteredSessions); - } - return []; - }, [filteredSessions]); - - // Update date groups when filtered sessions change - useEffect(() => { - startTransition(() => { - setDateGroups(memoizedDateGroups); - }); - }, [memoizedDateGroups]); + useEffect(() => { + loadSessions(); + }, [loadSessions]); - // Scroll to the selected session when returning from session history view - useEffect(() => { - if (selectedSessionId) { - const element = sessionRefs.current[selectedSessionId]; - if (element) { - element.scrollIntoView({ - block: "center" + // Timing logic to prevent flicker between skeleton and content on initial load + useEffect(() => { + if (!isLoading && showSkeleton) { + setShowSkeleton(false); + // Use startTransition for non-blocking content show + startTransition(() => { + setTimeout(() => { + setShowContent(true); + if (isInitialLoad) { + setIsInitialLoad(false); + } + }, 10); }); } - } - }, [selectedSessionId, sessions]); + return () => void 0; + }, [isLoading, showSkeleton, isInitialLoad]); - // Debounced search effect - performs actual filtering - useEffect(() => { - if (!debouncedSearchTerm) { + // Memoize date groups calculation to prevent unnecessary recalculations + const memoizedDateGroups = useMemo(() => { + if (filteredSessions.length > 0) { + return groupSessionsByDate(filteredSessions); + } + return []; + }, [filteredSessions]); + + // Update date groups when filtered sessions change + useEffect(() => { startTransition(() => { - setFilteredSessions(sessions); - setSearchResults(null); + setDateGroups(memoizedDateGroups); }); - return; - } - - // Use startTransition to make search non-blocking - startTransition(() => { - const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase(); - const filtered = sessions.filter((session) => { - const description = session.metadata.description || session.id; - const path = session.path; - const workingDir = session.metadata.working_dir; - - if (caseSensitive) { - return ( - description.includes(searchTerm) || - path.includes(searchTerm) || - workingDir.includes(searchTerm) - ); - } else { - return ( - description.toLowerCase().includes(searchTerm) || - path.toLowerCase().includes(searchTerm) || - workingDir.toLowerCase().includes(searchTerm) - ); + }, [memoizedDateGroups]); + + // Scroll to the selected session when returning from session history view + useEffect(() => { + if (selectedSessionId) { + const element = sessionRefs.current[selectedSessionId]; + if (element) { + element.scrollIntoView({ + block: 'center', + }); } + } + }, [selectedSessionId, sessions]); + + // Debounced search effect - performs actual filtering + useEffect(() => { + if (!debouncedSearchTerm) { + startTransition(() => { + setFilteredSessions(sessions); + setSearchResults(null); + }); + return; + } + + // Use startTransition to make search non-blocking + startTransition(() => { + const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase(); + const filtered = sessions.filter((session) => { + const description = session.metadata.description || session.id; + const path = session.path; + const workingDir = session.metadata.working_dir; + + if (caseSensitive) { + return ( + description.includes(searchTerm) || + path.includes(searchTerm) || + workingDir.includes(searchTerm) + ); + } else { + return ( + description.toLowerCase().includes(searchTerm) || + path.toLowerCase().includes(searchTerm) || + workingDir.toLowerCase().includes(searchTerm) + ); + } + }); + + setFilteredSessions(filtered); + setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null); }); + }, [debouncedSearchTerm, caseSensitive, sessions]); - setFilteredSessions(filtered); - setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null); - }); - }, [debouncedSearchTerm, caseSensitive, sessions]); - - // Handle immediate search input (updates search term for debouncing) - const handleSearch = useCallback((term: string, caseSensitive: boolean) => { - setSearchTerm(term); - setCaseSensitive(caseSensitive); - }, []); - - // Handle search result navigation - const handleSearchNavigation = (direction: 'next' | 'prev') => { - if (!searchResults || filteredSessions.length === 0) return; - - let newIndex: number; - if (direction === 'next') { - newIndex = (searchResults.currentIndex % filteredSessions.length) + 1; - } else { - newIndex = - searchResults.currentIndex === 1 ? filteredSessions.length : searchResults.currentIndex - 1; - } - - setSearchResults({ ...searchResults, currentIndex: newIndex }); - - // Find the SearchView's container element - const searchContainer = - containerRef.current?.querySelector('.search-container'); - if (searchContainer?._searchHighlighter) { - // Update the current match in the highlighter - searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true); - } - }; - - // Handle modal close - const handleModalClose = useCallback(() => { - setShowEditModal(false); - setEditingSession(null); - }, []); - - const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => { - // Update state immediately for optimistic UI - setSessions((prevSessions) => - prevSessions.map((s) => - s.id === sessionId ? { ...s, metadata: { ...s.metadata, description: newDescription } } : s - ) - ); - }, []); - - const handleEditSession = useCallback((session: Session) => { - setEditingSession(session); - setShowEditModal(true); - }, []); - - const SessionItem = React.memo(function SessionItem({ - session, - onEditClick, - }: { - session: Session; - onEditClick: (session: Session) => void; - }) { - const handleEditClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); // Prevent card click - onEditClick(session); - }, - [onEditClick, session] - ); + // Handle immediate search input (updates search term for debouncing) + const handleSearch = useCallback((term: string, caseSensitive: boolean) => { + setSearchTerm(term); + setCaseSensitive(caseSensitive); + }, []); - const handleCardClick = useCallback(() => { - onSelectSession(session.id); - }, [session.id]); + // Handle search result navigation + const handleSearchNavigation = (direction: 'next' | 'prev') => { + if (!searchResults || filteredSessions.length === 0) return; + + let newIndex: number; + if (direction === 'next') { + newIndex = (searchResults.currentIndex % filteredSessions.length) + 1; + } else { + newIndex = + searchResults.currentIndex === 1 + ? filteredSessions.length + : searchResults.currentIndex - 1; + } - return ( - setSessionRefs(session.id, el)} - > - + setSearchResults({ ...searchResults, currentIndex: newIndex }); -
-

- {session.metadata.description || session.id} -

+ // Find the SearchView's container element + const searchContainer = + containerRef.current?.querySelector('.search-container'); + if (searchContainer?._searchHighlighter) { + // Update the current match in the highlighter + searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true); + } + }; -
- - {formatMessageTimestamp(Date.parse(session.modified) / 1000)} -
-
- - {session.metadata.working_dir} -
-
+ // Handle modal close + const handleModalClose = useCallback(() => { + setShowEditModal(false); + setEditingSession(null); + }, []); + + const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => { + // Update state immediately for optimistic UI + setSessions((prevSessions) => + prevSessions.map((s) => + s.id === sessionId + ? { ...s, metadata: { ...s.metadata, description: newDescription } } + : s + ) + ); + }, []); + + const handleEditSession = useCallback((session: Session) => { + setEditingSession(session); + setShowEditModal(true); + }, []); + + const SessionItem = React.memo(function SessionItem({ + session, + onEditClick, + }: { + session: Session; + onEditClick: (session: Session) => void; + }) { + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + onEditClick(session); + }, + [onEditClick, session] + ); + + const handleCardClick = useCallback(() => { + onSelectSession(session.id); + }, [session.id]); -
-
-
- - {session.metadata.message_count} + return ( + setSessionRefs(session.id, el)} + > + + +
+

+ {session.metadata.description || session.id} +

+ +
+ + {formatMessageTimestamp(Date.parse(session.modified) / 1000)}
- {session.metadata.total_tokens !== null && ( +
+ + {session.metadata.working_dir} +
+
+ +
+
- - {session.metadata.total_tokens.toLocaleString()} + + {session.metadata.message_count}
- )} + {session.metadata.total_tokens !== null && ( +
+ + + {session.metadata.total_tokens.toLocaleString()} + +
+ )} +
-
- - ); - }); - - // Render skeleton loader for session items with variations - const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { - const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; - const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; - const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; + + ); + }); - return ( - -
- -
- - -
-
- - -
-
+ // Render skeleton loader for session items with variations + const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { + const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; + const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; + const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; -
-
-
+ return ( + +
+ +
- +
-
+
- +
-
-
- ); - }); - - SessionSkeleton.displayName = 'SessionSkeleton'; - const renderActualContent = () => { - if (error) { - return ( -
- -

Error Loading Sessions

-

{error}

- -
+
+
+
+ + +
+
+ + +
+
+
+ ); - } + }); - if (sessions.length === 0) { - return ( -
- -

No chat sessions found

-

Your chat history will appear here

-
- ); - } + SessionSkeleton.displayName = 'SessionSkeleton'; + + const renderActualContent = () => { + if (error) { + return ( +
+ +

Error Loading Sessions

+

{error}

+ +
+ ); + } - if (dateGroups.length === 0 && searchResults !== null) { + if (sessions.length === 0) { + return ( +
+ +

No chat sessions found

+

Your chat history will appear here

+
+ ); + } + + if (dateGroups.length === 0 && searchResults !== null) { + return ( +
+ +

No matching sessions found

+

Try adjusting your search terms

+
+ ); + } + + // For regular rendering in grid layout return ( -
- -

No matching sessions found

-

Try adjusting your search terms

+
+ {dateGroups.map((group) => ( +
+
+

{group.label}

+
+
+ {group.sessions.map((session) => ( + + ))} +
+
+ ))}
); - } + }; - // For regular rendering in grid layout return ( -
- {dateGroups.map((group) => ( -
-
-

{group.label}

-
-
- {group.sessions.map((session) => ( - - ))} -
-
- ))} -
- ); - }; - - return ( - <> - -
-
-
-
-

Chat history

+ <> + +
+
+
+
+

Chat history

+
+

+ View and search your past conversations with Goose. +

-

- View and search your past conversations with Goose. -

-
-
- -
- - {/* Skeleton layer - always rendered but conditionally visible */} -
+ +
+ -
- {/* Today section */} -
- -
- - - - - + {/* Skeleton layer - always rendered but conditionally visible */} +
+
+ {/* Today section */} +
+ +
+ + + + + +
-
- {/* Yesterday section */} -
- -
- - - - - - + {/* Yesterday section */} +
+ +
+ + + + + + +
-
- {/* Additional section */} -
- -
- - - + {/* Additional section */} +
+ +
+ + + +
-
- {/* Content layer - always rendered but conditionally visible */} -
- {renderActualContent()} -
- -
- + {/* Content layer - always rendered but conditionally visible */} +
+ {renderActualContent()} +
+ +
+ +
-
- - - - - ); -}); + + + + + ); + } +); SessionListView.displayName = 'SessionListView'; diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index bd40aa051baf..fde07f99eb20 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -1,3 +1,4 @@ +import { listRecipes, RecipeManifestResponse } from '../api'; import { Recipe } from './index'; import * as yaml from 'yaml'; @@ -165,181 +166,21 @@ export async function loadRecipe(recipeName: string, isGlobal: boolean): Promise } } -/** - * List all saved recipes from the recipes directories. - * - * Uses the listFiles API to find available recipe files. - */ -export async function listSavedRecipes(includeArchived: boolean = false): Promise { +export async function listSavedRecipes(): Promise { try { - // Check for global and local recipe directories - const globalDir = getStorageDirectory(true); - const localDir = getStorageDirectory(false); - - // Ensure directories exist - await window.electron.ensureDirectory(globalDir); - await window.electron.ensureDirectory(localDir); - - // Get list of recipe files with .yaml extension - const globalFiles = await window.electron.listFiles(globalDir, 'yaml'); - const localFiles = await window.electron.listFiles(localDir, 'yaml'); - - // Process global recipes in parallel - const globalRecipePromises = globalFiles.map(async (file) => { - const recipeName = file.replace(/\.yaml$/, ''); - return await loadRecipeFromFile(recipeName, true); - }); - - // Process local recipes in parallel - const localRecipePromises = localFiles.map(async (file) => { - const recipeName = file.replace(/\.yaml$/, ''); - return await loadRecipeFromFile(recipeName, false); - }); - - // Wait for all recipes to load in parallel - const [globalRecipes, localRecipes] = await Promise.all([ - Promise.all(globalRecipePromises), - Promise.all(localRecipePromises), - ]); - - // Filter out null results and apply archived filter - const recipes: SavedRecipe[] = []; - - for (const recipe of globalRecipes) { - if (recipe && (includeArchived || !recipe.isArchived)) { - recipes.push(recipe); - } - } - - for (const recipe of localRecipes) { - if (recipe && (includeArchived || !recipe.isArchived)) { - recipes.push(recipe); - } - } - - // Sort by last modified (newest first) - return recipes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + const listRecipeResponse = await listRecipes(); + return listRecipeResponse?.data?.recipe_manifest_responses ?? []; } catch (error) { console.warn('Failed to list saved recipes:', error); return []; } } -/** - * Restore an archived recipe. - * - * @param recipeName The name of the recipe to restore - * @param isGlobal Whether the recipe is in global or local storage - */ -export async function restoreRecipe(recipeName: string, isGlobal: boolean): Promise { - try { - const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); - - if (!savedRecipe) { - throw new Error('Archived recipe not found'); - } - - if (!savedRecipe.isArchived) { - throw new Error('Recipe is not archived'); - } - - // Mark as not archived - savedRecipe.isArchived = false; - savedRecipe.lastModified = new Date(); - - // Save back to file - const success = await saveRecipeToFile(savedRecipe); - - if (!success) { - throw new Error('Failed to save updated recipe'); - } - } catch (error) { - throw new Error( - `Failed to restore recipe: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Archive a recipe. - * - * @param recipeName The name of the recipe to archive - * @param isGlobal Whether the recipe is in global or local storage - */ -export async function archiveRecipe(recipeName: string, isGlobal: boolean): Promise { - try { - const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); - - if (!savedRecipe) { - throw new Error('Recipe not found'); - } - - if (savedRecipe.isArchived) { - throw new Error('Recipe is already archived'); - } - - // Mark as archived - savedRecipe.isArchived = true; - savedRecipe.lastModified = new Date(); - - // Save back to file - const success = await saveRecipeToFile(savedRecipe); - - if (!success) { - throw new Error('Failed to save updated recipe'); - } - } catch (error) { - throw new Error( - `Failed to archive recipe: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Permanently delete a recipe file. - * - * @param recipeName The name of the recipe to permanently delete - * @param isGlobal Whether the recipe is in global or local storage - */ -export async function permanentlyDeleteRecipe( - recipeName: string, - isGlobal: boolean -): Promise { - try { - // TODO: Implement file deletion when available in the API - // For now, we'll just mark it as archived as a fallback - const savedRecipe = await loadRecipeFromFile(recipeName, isGlobal); - - if (!savedRecipe) { - throw new Error('Recipe not found'); - } - - // Mark as archived with special flag - savedRecipe.isArchived = true; - savedRecipe.lastModified = new Date(); - - // Save back to file - const success = await saveRecipeToFile(savedRecipe); - - if (!success) { - throw new Error('Failed to mark recipe as deleted'); - } - } catch (error) { - throw new Error( - `Failed to delete recipe: ${error instanceof Error ? error.message : 'Unknown error'}` - ); +export function convertToLocaleDateString(lastModified: string): string { + if (lastModified) { + return parseLastModified(lastModified).toLocaleDateString(); } -} - -/** - * Delete a recipe (archives it by default for backward compatibility). - * - * @deprecated Use archiveRecipe instead - * @param recipeName The name of the recipe to delete/archive - * @param isGlobal Whether the recipe is in global or local storage - */ -export async function deleteRecipe(recipeName: string, isGlobal: boolean): Promise { - return archiveRecipe(recipeName, isGlobal); + return ''; } /**