mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
chore: move list recipes and archive recipe to goose server (#4422)
This commit is contained in:
Generated
+1
@@ -2766,6 +2766,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
||||
@@ -53,3 +53,4 @@ path = "src/bin/generate_schema.rs"
|
||||
[dev-dependencies]
|
||||
tower = "0.5"
|
||||
async-trait = "0.1"
|
||||
tempfile = "3.15.0"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RecipeManifestResponse>,
|
||||
}
|
||||
|
||||
#[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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<ListRecipeResponse>, 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::<Vec<RecipeManifestResponse>>();
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DeleteRecipeRequest>,
|
||||
) -> 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<AppState>) -> 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<RecipeManifestWithPath>> {
|
||||
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::<chrono::Utc>::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<Vec<RecipeManifestWithPath>> {
|
||||
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<Self> {
|
||||
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::<Self>(&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);
|
||||
}
|
||||
}
|
||||
@@ -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<AgentRef>,
|
||||
pub secret_key: String,
|
||||
pub scheduler: Arc<Mutex<Option<Arc<dyn SchedulerTrait>>>>,
|
||||
pub recipe_file_hash_map: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||
}
|
||||
|
||||
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<String, PathBuf>) {
|
||||
let mut map = self.recipe_file_hash_map.lock().await;
|
||||
*map = hash_map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||
@@ -259,6 +259,17 @@ export const decodeRecipe = <ThrowOnError extends boolean = false>(options: Opti
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteRecipe = <ThrowOnError extends boolean = false>(options: Options<DeleteRecipeData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<DeleteRecipeResponses, DeleteRecipeErrors, ThrowOnError>({
|
||||
url: '/recipes/delete',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const encodeRecipe = <ThrowOnError extends boolean = false>(options: Options<EncodeRecipeData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<EncodeRecipeResponses, EncodeRecipeErrors, ThrowOnError>({
|
||||
url: '/recipes/encode',
|
||||
@@ -270,6 +281,13 @@ export const encodeRecipe = <ThrowOnError extends boolean = false>(options: Opti
|
||||
});
|
||||
};
|
||||
|
||||
export const listRecipes = <ThrowOnError extends boolean = false>(options?: Options<ListRecipesData, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).get<ListRecipesResponses, ListRecipesErrors, ThrowOnError>({
|
||||
url: '/recipes/list',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const scanRecipe = <ThrowOnError extends boolean = false>(options: Options<ScanRecipeData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<ScanRecipeResponses, unknown, ThrowOnError>({
|
||||
url: '/recipes/scan',
|
||||
|
||||
@@ -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<RecipeManifestResponse>;
|
||||
};
|
||||
|
||||
export type ListSchedulesResponse = {
|
||||
jobs: Array<ScheduledJob>;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -227,7 +227,7 @@ export default function ProgressiveMessageList({
|
||||
toolCallNotifications,
|
||||
isStreamingMessage,
|
||||
onMessageUpdate,
|
||||
hasCompactionMarker
|
||||
hasCompactionMarker,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<SavedRecipe[]>([]);
|
||||
const [savedRecipes, setSavedRecipes] = useState<RecipeManifestResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifestResponse | null>(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 (
|
||||
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base truncate max-w-[50vw]">{savedRecipe.recipe.title}</h3>
|
||||
{savedRecipe.isGlobal ? (
|
||||
<Globe className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-sm mb-2 line-clamp-2">
|
||||
{savedRecipe.recipe.description}
|
||||
</p>
|
||||
<div className="flex items-center text-xs text-text-muted">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{savedRecipe.lastModified.toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
// Render a recipe item
|
||||
const RecipeItem = ({
|
||||
recipeManifestResponse,
|
||||
recipeManifestResponse: { recipe, lastModified },
|
||||
}: {
|
||||
recipeManifestResponse: RecipeManifestResponse;
|
||||
}) => (
|
||||
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base truncate max-w-[50vw]">{recipe.title}</h3>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm mb-2 line-clamp-2">{recipe.description}</p>
|
||||
<div className="flex items-center text-xs text-text-muted">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{convertToLocaleDateString(lastModified)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLoadRecipe(savedRecipe);
|
||||
}}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Bot className="w-4 h-4 mr-1" />
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewRecipe(savedRecipe);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRecipe(savedRecipe);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
} catch (error) {
|
||||
// Error row showing failed to read file with filename and error details
|
||||
return (
|
||||
<Card className="py-2 px-4 mb-2 bg-red-50 border border-red-200 dark:bg-red-900/20 dark:border-red-800">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||
<h3 className="text-base text-red-700 dark:text-red-300">
|
||||
Failed to read file: {savedRecipe.filename}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLoadRecipe(recipe);
|
||||
}}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Bot className="w-4 h-4 mr-1" />
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewRecipe(recipeManifestResponse);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRecipe(recipeManifestResponse);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Render skeleton loader for recipe items
|
||||
const RecipeSkeleton = () => (
|
||||
@@ -515,10 +484,10 @@ Parameters you can use:
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{savedRecipes.map((savedRecipe) => (
|
||||
{savedRecipes.map((recipeManifestResponse: RecipeManifestResponse) => (
|
||||
<RecipeItem
|
||||
key={`${savedRecipe.isGlobal ? 'global' : 'local'}-${savedRecipe.name}`}
|
||||
savedRecipe={savedRecipe}
|
||||
key={recipeManifestResponse.id}
|
||||
recipeManifestResponse={recipeManifestResponse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -584,9 +553,6 @@ Parameters you can use:
|
||||
<h3 className="text-xl font-medium text-text-standard">
|
||||
{selectedRecipe.recipe.title}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
@@ -921,18 +887,6 @@ Parameters you can use:
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRecipe.recipe.goosehints && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-standard mb-2">Goose Hints</h4>
|
||||
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
|
||||
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
|
||||
{selectedRecipe.recipe.goosehints}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRecipe.recipe.context && selectedRecipe.recipe.context.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-standard mb-2">Context</h4>
|
||||
@@ -949,30 +903,6 @@ Parameters you can use:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRecipe.recipe.profile && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-standard mb-2">Profile</h4>
|
||||
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
|
||||
<span className="text-sm text-text-muted font-mono">
|
||||
{selectedRecipe.recipe.profile}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRecipe.recipe.mcps && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-standard mb-2">
|
||||
Max Completion Tokens per Second
|
||||
</h4>
|
||||
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
|
||||
<span className="text-sm text-text-muted font-mono">
|
||||
{selectedRecipe.recipe.mcps}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRecipe.recipe.author && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-standard mb-2">Author</h4>
|
||||
@@ -1001,7 +931,7 @@ Parameters you can use:
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowPreview(false);
|
||||
handleLoadRecipe(selectedRecipe);
|
||||
handleLoadRecipe(selectedRecipe.recipe);
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByText('Test info message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('AlertBox', () => {
|
||||
|
||||
const { container } = render(<AlertBox alert={alert} />);
|
||||
const alertElement = container.querySelector('.bg-\\[\\#cc4b03\\]');
|
||||
|
||||
|
||||
expect(alertElement).toBeInTheDocument();
|
||||
expect(screen.getByText('Test warning message')).toBeInTheDocument();
|
||||
});
|
||||
@@ -44,7 +44,7 @@ describe('AlertBox', () => {
|
||||
|
||||
const { container } = render(<AlertBox alert={alert} />);
|
||||
const alertElement = container.querySelector('.bg-\\[\\#d7040e\\]');
|
||||
|
||||
|
||||
expect(alertElement).toBeInTheDocument();
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument();
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe('AlertBox', () => {
|
||||
|
||||
const { container } = render(<AlertBox alert={alert} className="custom-class" />);
|
||||
const alertElement = container.firstChild as HTMLElement;
|
||||
|
||||
|
||||
expect(alertElement).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
@@ -74,13 +74,15 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
|
||||
|
||||
// Check progress bar exists
|
||||
const progressDots = screen.getByText('Context window').parentElement?.parentElement?.querySelectorAll('.h-\\[2px\\]');
|
||||
const progressDots = screen
|
||||
.getByText('Context window')
|
||||
.parentElement?.parentElement?.querySelectorAll('.h-\\[2px\\]');
|
||||
expect(progressDots).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -95,7 +97,7 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
@@ -112,7 +114,7 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
// Use getAllByText since there are multiple "100" elements (current and total)
|
||||
const hundredElements = screen.getAllByText('100');
|
||||
expect(hundredElements).toHaveLength(2); // One for current, one for total
|
||||
@@ -130,7 +132,7 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByText('1.5k')).toBeInTheDocument();
|
||||
expect(screen.getByText('15%')).toBeInTheDocument();
|
||||
expect(screen.getByText('10k')).toBeInTheDocument();
|
||||
@@ -147,7 +149,7 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByText('150')).toBeInTheDocument();
|
||||
expect(screen.getByText('150%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
@@ -165,13 +167,13 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByText('Compact now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render compact button with custom icon', () => {
|
||||
const CompactIcon = () => <span data-testid="compact-icon">📦</span>;
|
||||
|
||||
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
@@ -182,14 +184,14 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByTestId('compact-icon')).toBeInTheDocument();
|
||||
expect(screen.getByText('Compact now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onCompact when compact button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
@@ -199,16 +201,16 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
const compactButton = screen.getByText('Compact now');
|
||||
await user.click(compactButton);
|
||||
|
||||
|
||||
expect(mockOnCompact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should prevent event propagation when compact button is clicked', () => {
|
||||
const mockParentClick = vi.fn();
|
||||
|
||||
|
||||
const alert: Alert = {
|
||||
type: AlertType.Info,
|
||||
message: 'Context window',
|
||||
@@ -222,10 +224,10 @@ describe('AlertBox', () => {
|
||||
<AlertBox alert={alert} />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
const compactButton = screen.getByText('Compact now');
|
||||
fireEvent.click(compactButton);
|
||||
|
||||
|
||||
expect(mockOnCompact).toHaveBeenCalledTimes(1);
|
||||
expect(mockParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -240,7 +242,7 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.queryByText('Compact now')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -253,7 +255,7 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.queryByText('Compact now')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -272,7 +274,7 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByText('75')).toBeInTheDocument();
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
@@ -286,9 +288,14 @@ describe('AlertBox', () => {
|
||||
};
|
||||
|
||||
render(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
// 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(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
// 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(<AlertBox alert={alert} />);
|
||||
|
||||
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
// Progress percentage would be Infinity, but it should still render
|
||||
|
||||
@@ -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: <span>Icon</span>,
|
||||
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: <span>📦</span>,
|
||||
};
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,449 +158,457 @@ interface SessionListViewProps {
|
||||
selectedSessionId?: string | null;
|
||||
}
|
||||
|
||||
const SessionListView: React.FC<SessionListViewProps> = React.memo(({ onSelectSession, selectedSessionId }) => {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
|
||||
const [dateGroups, setDateGroups] = useState<DateGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
count: number;
|
||||
currentIndex: number;
|
||||
} | null>(null);
|
||||
const SessionListView: React.FC<SessionListViewProps> = React.memo(
|
||||
({ onSelectSession, selectedSessionId }) => {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [filteredSessions, setFilteredSessions] = useState<Session[]>([]);
|
||||
const [dateGroups, setDateGroups] = useState<DateGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
count: number;
|
||||
currentIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
// Edit modal state
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingSession, setEditingSession] = useState<Session | null>(null);
|
||||
// Edit modal state
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingSession, setEditingSession] = useState<Session | null>(null);
|
||||
|
||||
// Search state for debouncing
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce
|
||||
// Search state for debouncing
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track session to element ref
|
||||
const sessionRefs = useRef<Record<string, HTMLElement>>({});
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Scroll to the selected session when returning from session history view
|
||||
useEffect(() => {
|
||||
if (selectedSessionId) {
|
||||
const element = sessionRefs.current[selectedSessionId];
|
||||
// Track session to element ref
|
||||
const sessionRefs = useRef<Record<string, HTMLElement>>({});
|
||||
const setSessionRefs = (itemId: string, element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "center"
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}, [memoizedDateGroups]);
|
||||
|
||||
// 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)
|
||||
);
|
||||
// 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 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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
setSearchResults({ ...searchResults, currentIndex: newIndex });
|
||||
// Find the SearchView's container element
|
||||
const searchContainer =
|
||||
containerRef.current?.querySelector<SearchContainerElement>('.search-container');
|
||||
if (searchContainer?._searchHighlighter) {
|
||||
// Update the current match in the highlighter
|
||||
searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true);
|
||||
}
|
||||
};
|
||||
|
||||
// Find the SearchView's container element
|
||||
const searchContainer =
|
||||
containerRef.current?.querySelector<SearchContainerElement>('.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);
|
||||
}, []);
|
||||
|
||||
// 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 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 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 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]);
|
||||
|
||||
const handleCardClick = useCallback(() => {
|
||||
onSelectSession(session.id);
|
||||
}, [session.id]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={handleCardClick}
|
||||
className="session-item h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between relative group"
|
||||
ref={(el) => setSessionRefs(session.id, el)}
|
||||
>
|
||||
<button
|
||||
onClick={handleEditClick}
|
||||
className="absolute top-3 right-4 p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
|
||||
title="Edit session name"
|
||||
return (
|
||||
<Card
|
||||
onClick={handleCardClick}
|
||||
className="session-item h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between relative group"
|
||||
ref={(el) => setSessionRefs(session.id, el)}
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-textSubtle hover:text-textStandard" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditClick}
|
||||
className="absolute top-3 right-4 p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
|
||||
title="Edit session name"
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-textSubtle hover:text-textStandard" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base mb-1 pr-6 break-words">
|
||||
{session.metadata.description || session.id}
|
||||
</h3>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base mb-1 pr-6 break-words">
|
||||
{session.metadata.description || session.id}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center text-text-muted text-xs mb-1">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-text-muted text-xs mb-1">
|
||||
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{session.metadata.working_dir}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-1 pt-2">
|
||||
<div className="flex items-center space-x-3 text-xs text-text-muted">
|
||||
<div className="flex items-center">
|
||||
<MessageSquareText className="w-3 h-3 mr-1" />
|
||||
<span className="font-mono">{session.metadata.message_count}</span>
|
||||
<div className="flex items-center text-text-muted text-xs mb-1">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
|
||||
</div>
|
||||
{session.metadata.total_tokens !== null && (
|
||||
<div className="flex items-center text-text-muted text-xs mb-1">
|
||||
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{session.metadata.working_dir}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-1 pt-2">
|
||||
<div className="flex items-center space-x-3 text-xs text-text-muted">
|
||||
<div className="flex items-center">
|
||||
<Target className="w-3 h-3 mr-1" />
|
||||
<span className="font-mono">{session.metadata.total_tokens.toLocaleString()}</span>
|
||||
<MessageSquareText className="w-3 h-3 mr-1" />
|
||||
<span className="font-mono">{session.metadata.message_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{session.metadata.total_tokens !== null && (
|
||||
<div className="flex items-center">
|
||||
<Target className="w-3 h-3 mr-1" />
|
||||
<span className="font-mono">
|
||||
{session.metadata.total_tokens.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
// 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'];
|
||||
// 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 (
|
||||
<Card className="session-skeleton h-full py-3 px-4 flex flex-col justify-between">
|
||||
<div className="flex-1">
|
||||
<Skeleton className={`h-5 ${titleWidths[variant % titleWidths.length]} mb-2`} />
|
||||
<div className="flex items-center mb-1">
|
||||
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="flex items-center mb-1">
|
||||
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
|
||||
<Skeleton className={`h-4 ${pathWidths[variant % pathWidths.length]}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-1 pt-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center">
|
||||
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
|
||||
<Skeleton className={`h-4 ${tokenWidths[variant % tokenWidths.length]}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
SessionSkeleton.displayName = 'SessionSkeleton';
|
||||
|
||||
const renderActualContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||
<p className="text-lg mb-2">Error Loading Sessions</p>
|
||||
<p className="text-sm text-center mb-4">{error}</p>
|
||||
<Button onClick={loadSessions} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center h-full text-text-muted">
|
||||
<MessageSquareText className="h-12 w-12 mb-4" />
|
||||
<p className="text-lg mb-2">No chat sessions found</p>
|
||||
<p className="text-sm">Your chat history will appear here</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dateGroups.length === 0 && searchResults !== null) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted mt-4">
|
||||
<MessageSquareText className="h-12 w-12 mb-4" />
|
||||
<p className="text-lg mb-2">No matching sessions found</p>
|
||||
<p className="text-sm">Try adjusting your search terms</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For regular rendering in grid layout
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{dateGroups.map((group) => (
|
||||
<div key={group.label} className="space-y-4">
|
||||
<div className="sticky top-0 z-10 bg-background-default/95 backdrop-blur-sm">
|
||||
<h2 className="text-text-muted">{group.label}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{group.sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} onEditClick={handleEditSession} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="session-skeleton h-full py-3 px-4 flex flex-col justify-between">
|
||||
<div className="flex-1">
|
||||
<Skeleton className={`h-5 ${titleWidths[variant % titleWidths.length]} mb-2`} />
|
||||
<div className="flex items-center mb-1">
|
||||
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="flex items-center mb-1">
|
||||
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
|
||||
<Skeleton className={`h-4 ${pathWidths[variant % pathWidths.length]}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-1 pt-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center">
|
||||
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Skeleton className="h-3 w-3 mr-1 rounded-sm" />
|
||||
<Skeleton className={`h-4 ${tokenWidths[variant % tokenWidths.length]}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
SessionSkeleton.displayName = 'SessionSkeleton';
|
||||
|
||||
const renderActualContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||
<p className="text-lg mb-2">Error Loading Sessions</p>
|
||||
<p className="text-sm text-center mb-4">{error}</p>
|
||||
<Button onClick={loadSessions} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center h-full text-text-muted">
|
||||
<MessageSquareText className="h-12 w-12 mb-4" />
|
||||
<p className="text-lg mb-2">No chat sessions found</p>
|
||||
<p className="text-sm">Your chat history will appear here</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dateGroups.length === 0 && searchResults !== null) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted mt-4">
|
||||
<MessageSquareText className="h-12 w-12 mb-4" />
|
||||
<p className="text-lg mb-2">No matching sessions found</p>
|
||||
<p className="text-sm">Try adjusting your search terms</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For regular rendering in grid layout
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{dateGroups.map((group) => (
|
||||
<div key={group.label} className="space-y-4">
|
||||
<div className="sticky top-0 z-10 bg-background-default/95 backdrop-blur-sm">
|
||||
<h2 className="text-text-muted">{group.label}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{group.sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} onEditClick={handleEditSession} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">Chat history</h1>
|
||||
<>
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">Chat history</h1>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
View and search your past conversations with Goose.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
View and search your past conversations with Goose.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 relative px-8">
|
||||
<ScrollArea className="h-full" data-search-scroll-area>
|
||||
<div ref={containerRef} className="h-full relative">
|
||||
<SearchView
|
||||
onSearch={handleSearch}
|
||||
onNavigate={handleSearchNavigation}
|
||||
searchResults={searchResults}
|
||||
className="relative"
|
||||
>
|
||||
{/* Skeleton layer - always rendered but conditionally visible */}
|
||||
<div
|
||||
className={`absolute inset-0 transition-opacity duration-300 ${
|
||||
isLoading || showSkeleton
|
||||
? 'opacity-100 z-10'
|
||||
: 'opacity-0 z-0 pointer-events-none'
|
||||
}`}
|
||||
<div className="flex-1 min-h-0 relative px-8">
|
||||
<ScrollArea className="h-full" data-search-scroll-area>
|
||||
<div ref={containerRef} className="h-full relative">
|
||||
<SearchView
|
||||
onSearch={handleSearch}
|
||||
onNavigate={handleSearchNavigation}
|
||||
searchResults={searchResults}
|
||||
className="relative"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{/* Today section */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<SessionSkeleton variant={0} />
|
||||
<SessionSkeleton variant={1} />
|
||||
<SessionSkeleton variant={2} />
|
||||
<SessionSkeleton variant={3} />
|
||||
<SessionSkeleton variant={0} />
|
||||
{/* Skeleton layer - always rendered but conditionally visible */}
|
||||
<div
|
||||
className={`absolute inset-0 transition-opacity duration-300 ${
|
||||
isLoading || showSkeleton
|
||||
? 'opacity-100 z-10'
|
||||
: 'opacity-0 z-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{/* Today section */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-16" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<SessionSkeleton variant={0} />
|
||||
<SessionSkeleton variant={1} />
|
||||
<SessionSkeleton variant={2} />
|
||||
<SessionSkeleton variant={3} />
|
||||
<SessionSkeleton variant={0} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yesterday section */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<SessionSkeleton variant={1} />
|
||||
<SessionSkeleton variant={2} />
|
||||
<SessionSkeleton variant={3} />
|
||||
<SessionSkeleton variant={0} />
|
||||
<SessionSkeleton variant={1} />
|
||||
<SessionSkeleton variant={2} />
|
||||
{/* Yesterday section */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<SessionSkeleton variant={1} />
|
||||
<SessionSkeleton variant={2} />
|
||||
<SessionSkeleton variant={3} />
|
||||
<SessionSkeleton variant={0} />
|
||||
<SessionSkeleton variant={1} />
|
||||
<SessionSkeleton variant={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional section */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<SessionSkeleton variant={3} />
|
||||
<SessionSkeleton variant={0} />
|
||||
<SessionSkeleton variant={1} />
|
||||
{/* Additional section */}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<SessionSkeleton variant={3} />
|
||||
<SessionSkeleton variant={0} />
|
||||
<SessionSkeleton variant={1} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content layer - always rendered but conditionally visible */}
|
||||
<div
|
||||
className={`relative transition-opacity duration-300 ${
|
||||
showContent ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||
}`}
|
||||
>
|
||||
{renderActualContent()}
|
||||
</div>
|
||||
</SearchView>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/* Content layer - always rendered but conditionally visible */}
|
||||
<div
|
||||
className={`relative transition-opacity duration-300 ${
|
||||
showContent ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||
}`}
|
||||
>
|
||||
{renderActualContent()}
|
||||
</div>
|
||||
</SearchView>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
</MainPanelLayout>
|
||||
|
||||
<EditSessionModal
|
||||
session={editingSession}
|
||||
isOpen={showEditModal}
|
||||
onClose={handleModalClose}
|
||||
onSave={handleModalSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
<EditSessionModal
|
||||
session={editingSession}
|
||||
isOpen={showEditModal}
|
||||
onClose={handleModalClose}
|
||||
onSave={handleModalSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SessionListView.displayName = 'SessionListView';
|
||||
|
||||
|
||||
@@ -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<SavedRecipe[]> {
|
||||
export async function listSavedRecipes(): Promise<RecipeManifestResponse[]> {
|
||||
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<void> {
|
||||
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'}`
|
||||
);
|
||||
export function convertToLocaleDateString(lastModified: string): string {
|
||||
if (lastModified) {
|
||||
return parseLastModified(lastModified).toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
return archiveRecipe(recipeName, isGlobal);
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user