mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
remove and cleanup unused code (#4074)
This commit is contained in:
@@ -7,7 +7,6 @@ use crate::commands::bench::agent_generator;
|
||||
use crate::commands::configure::handle_configure;
|
||||
use crate::commands::info::handle_info;
|
||||
use crate::commands::mcp::run_server;
|
||||
use crate::commands::project::{handle_project_default, handle_projects_interactive};
|
||||
use crate::commands::recipe::{handle_deeplink, handle_list, handle_validate};
|
||||
// Import the new handlers from commands::schedule
|
||||
use crate::commands::schedule::{
|
||||
@@ -387,14 +386,6 @@ enum Command {
|
||||
builtins: Vec<String>,
|
||||
},
|
||||
|
||||
/// Open the last project directory
|
||||
#[command(about = "Open the last project directory", visible_alias = "p")]
|
||||
Project {},
|
||||
|
||||
/// List recent project directories
|
||||
#[command(about = "List recent project directories", visible_alias = "ps")]
|
||||
Projects,
|
||||
|
||||
/// Execute commands from an instruction file
|
||||
#[command(about = "Execute commands from an instruction file or stdin")]
|
||||
Run {
|
||||
@@ -700,18 +691,11 @@ pub struct RecipeInfo {
|
||||
pub async fn cli() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Track the current directory in projects.json
|
||||
if let Err(e) = crate::project_tracker::update_project_tracker(None, None) {
|
||||
eprintln!("Warning: Failed to update project tracker: {}", e);
|
||||
}
|
||||
|
||||
let command_name = match &cli.command {
|
||||
Some(Command::Configure {}) => "configure",
|
||||
Some(Command::Info { .. }) => "info",
|
||||
Some(Command::Mcp { .. }) => "mcp",
|
||||
Some(Command::Session { .. }) => "session",
|
||||
Some(Command::Project {}) => "project",
|
||||
Some(Command::Projects) => "projects",
|
||||
Some(Command::Run { .. }) => "run",
|
||||
Some(Command::Schedule { .. }) => "schedule",
|
||||
Some(Command::Update { .. }) => "update",
|
||||
@@ -862,16 +846,6 @@ pub async fn cli() -> Result<()> {
|
||||
}
|
||||
};
|
||||
}
|
||||
Some(Command::Project {}) => {
|
||||
// Default behavior: offer to resume the last project
|
||||
handle_project_default()?;
|
||||
return Ok(());
|
||||
}
|
||||
Some(Command::Projects) => {
|
||||
// Interactive project selection
|
||||
handle_projects_interactive()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Some(Command::Run {
|
||||
instructions,
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod bench;
|
||||
pub mod configure;
|
||||
pub mod info;
|
||||
pub mod mcp;
|
||||
pub mod project;
|
||||
pub mod recipe;
|
||||
pub mod schedule;
|
||||
pub mod session;
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use chrono::DateTime;
|
||||
use cliclack::{self, intro, outro};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::project_tracker::ProjectTracker;
|
||||
use goose::utils::safe_truncate;
|
||||
|
||||
/// Format a DateTime for display
|
||||
fn format_date(date: DateTime<chrono::Utc>) -> String {
|
||||
// Format: "2025-05-08 18:15:30"
|
||||
date.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Handle the default project command
|
||||
///
|
||||
/// Offers options to resume the most recently accessed project
|
||||
pub fn handle_project_default() -> Result<()> {
|
||||
let tracker = ProjectTracker::load()?;
|
||||
let mut projects = tracker.list_projects();
|
||||
|
||||
if projects.is_empty() {
|
||||
// If no projects exist, just start a new one in the current directory
|
||||
println!("No previous projects found. Starting a new session in the current directory.");
|
||||
let mut command = std::process::Command::new("goose");
|
||||
command.arg("session");
|
||||
let status = command.status()?;
|
||||
|
||||
if !status.success() {
|
||||
println!("Failed to run Goose. Exit code: {:?}", status.code());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Sort projects by last_accessed (newest first)
|
||||
projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
|
||||
|
||||
// Get the most recent project
|
||||
let project = &projects[0];
|
||||
let project_dir = &project.path;
|
||||
|
||||
// Check if the directory exists
|
||||
if !Path::new(project_dir).exists() {
|
||||
println!(
|
||||
"Most recent project directory '{}' no longer exists.",
|
||||
project_dir
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Format the path for display
|
||||
let path = Path::new(project_dir);
|
||||
let components: Vec<_> = path.components().collect();
|
||||
let len = components.len();
|
||||
let short_path = if len <= 2 {
|
||||
project_dir.clone()
|
||||
} else {
|
||||
let mut path_str = String::new();
|
||||
path_str.push_str("...");
|
||||
for component in components.iter().skip(len - 2) {
|
||||
path_str.push('/');
|
||||
path_str.push_str(component.as_os_str().to_string_lossy().as_ref());
|
||||
}
|
||||
path_str
|
||||
};
|
||||
|
||||
// Ask the user what they want to do
|
||||
let _ = intro("Goose Project Manager");
|
||||
|
||||
let current_dir = std::env::current_dir()?;
|
||||
let current_dir_display = current_dir.display();
|
||||
|
||||
let choice = cliclack::select("Choose an option:")
|
||||
.item(
|
||||
"resume",
|
||||
format!("Resume project with session: {}", short_path),
|
||||
"Continue with the previous session",
|
||||
)
|
||||
.item(
|
||||
"fresh",
|
||||
format!("Resume project with fresh session: {}", short_path),
|
||||
"Change to the project directory but start a new session",
|
||||
)
|
||||
.item(
|
||||
"new",
|
||||
format!(
|
||||
"Start new project in current directory: {}",
|
||||
current_dir_display
|
||||
),
|
||||
"Stay in the current directory and start a new session",
|
||||
)
|
||||
.interact()?;
|
||||
|
||||
match choice {
|
||||
"resume" => {
|
||||
let _ = outro(format!("Changing to directory: {}", project_dir));
|
||||
|
||||
// Get the session ID if available
|
||||
let session_id = project.last_session_id.clone();
|
||||
|
||||
// Change to the project directory
|
||||
std::env::set_current_dir(project_dir)?;
|
||||
|
||||
// Build the command to run Goose
|
||||
let mut command = std::process::Command::new("goose");
|
||||
command.arg("session");
|
||||
|
||||
if let Some(id) = session_id {
|
||||
command.arg("--name").arg(&id).arg("--resume");
|
||||
println!("Resuming session: {}", id);
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
let status = command.status()?;
|
||||
|
||||
if !status.success() {
|
||||
println!("Failed to run Goose. Exit code: {:?}", status.code());
|
||||
}
|
||||
}
|
||||
"fresh" => {
|
||||
let _ = outro(format!(
|
||||
"Changing to directory: {} with a fresh session",
|
||||
project_dir
|
||||
));
|
||||
|
||||
// Change to the project directory
|
||||
std::env::set_current_dir(project_dir)?;
|
||||
|
||||
// Build the command to run Goose with a fresh session
|
||||
let mut command = std::process::Command::new("goose");
|
||||
command.arg("session");
|
||||
|
||||
// Execute the command
|
||||
let status = command.status()?;
|
||||
|
||||
if !status.success() {
|
||||
println!("Failed to run Goose. Exit code: {:?}", status.code());
|
||||
}
|
||||
}
|
||||
"new" => {
|
||||
let _ = outro("Starting a new session in the current directory");
|
||||
|
||||
// Build the command to run Goose
|
||||
let mut command = std::process::Command::new("goose");
|
||||
command.arg("session");
|
||||
|
||||
// Execute the command
|
||||
let status = command.status()?;
|
||||
|
||||
if !status.success() {
|
||||
println!("Failed to run Goose. Exit code: {:?}", status.code());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = outro("Operation canceled");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle the interactive projects command
|
||||
///
|
||||
/// Shows a list of projects and lets the user select one to resume
|
||||
pub fn handle_projects_interactive() -> Result<()> {
|
||||
let tracker = ProjectTracker::load()?;
|
||||
let mut projects = tracker.list_projects();
|
||||
|
||||
if projects.is_empty() {
|
||||
println!("No projects found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Sort projects by last_accessed (newest first)
|
||||
projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
|
||||
|
||||
// Format project paths for display
|
||||
let project_choices: Vec<(String, String)> = projects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, project)| {
|
||||
let path = Path::new(&project.path);
|
||||
let components: Vec<_> = path.components().collect();
|
||||
let len = components.len();
|
||||
let short_path = if len <= 2 {
|
||||
project.path.clone()
|
||||
} else {
|
||||
let mut path_str = String::new();
|
||||
path_str.push_str("...");
|
||||
for component in components.iter().skip(len - 2) {
|
||||
path_str.push('/');
|
||||
path_str.push_str(component.as_os_str().to_string_lossy().as_ref());
|
||||
}
|
||||
path_str
|
||||
};
|
||||
|
||||
// Include last instruction if available (truncated)
|
||||
let instruction_preview =
|
||||
project
|
||||
.last_instruction
|
||||
.as_ref()
|
||||
.map_or(String::new(), |instr| {
|
||||
let truncated = safe_truncate(instr, 40);
|
||||
format!(" [{}]", truncated)
|
||||
});
|
||||
|
||||
let formatted_date = format_date(project.last_accessed);
|
||||
(
|
||||
format!("{}", i + 1), // Value to return
|
||||
format!("{} ({}){}", short_path, formatted_date, instruction_preview), // Display text with instruction
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Let the user select a project
|
||||
let _ = intro("Goose Project Manager");
|
||||
let mut select = cliclack::select("Select a project:");
|
||||
|
||||
// Add each project as an option
|
||||
for (value, display) in &project_choices {
|
||||
select = select.item(value, display, "");
|
||||
}
|
||||
|
||||
// Add a cancel option
|
||||
let cancel_value = String::from("cancel");
|
||||
select = select.item(&cancel_value, "Cancel", "Don't resume any project");
|
||||
|
||||
let selected = select.interact()?;
|
||||
|
||||
if selected == "cancel" {
|
||||
let _ = outro("Project selection canceled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Parse the selected index
|
||||
let index = selected.parse::<usize>().unwrap_or(0);
|
||||
if index == 0 || index > projects.len() {
|
||||
let _ = outro("Invalid selection.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get the selected project
|
||||
let project = &projects[index - 1];
|
||||
let project_dir = &project.path;
|
||||
|
||||
// Check if the directory exists
|
||||
if !Path::new(project_dir).exists() {
|
||||
let _ = outro(format!(
|
||||
"Project directory '{}' no longer exists.",
|
||||
project_dir
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ask if the user wants to resume the session or start a new one
|
||||
let session_id = project.last_session_id.clone();
|
||||
let has_previous_session = session_id.is_some();
|
||||
|
||||
// Change to the project directory first
|
||||
std::env::set_current_dir(project_dir)?;
|
||||
let _ = outro(format!("Changed to directory: {}", project_dir));
|
||||
|
||||
// Only ask about resuming if there's a previous session
|
||||
let resume_session = if has_previous_session {
|
||||
let session_choice = cliclack::select("What would you like to do?")
|
||||
.item(
|
||||
"resume",
|
||||
"Resume previous session",
|
||||
"Continue with the previous session",
|
||||
)
|
||||
.item(
|
||||
"new",
|
||||
"Start new session",
|
||||
"Start a fresh session in this project directory",
|
||||
)
|
||||
.interact()?;
|
||||
|
||||
session_choice == "resume"
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Build the command to run Goose
|
||||
let mut command = std::process::Command::new("goose");
|
||||
command.arg("session");
|
||||
|
||||
if resume_session {
|
||||
if let Some(id) = session_id {
|
||||
command.arg("--name").arg(&id).arg("--resume");
|
||||
println!("Resuming session: {}", id);
|
||||
}
|
||||
} else {
|
||||
println!("Starting new session");
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
let status = command.status()?;
|
||||
|
||||
if !status.success() {
|
||||
println!("Failed to run Goose. Exit code: {:?}", status.code());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,7 +3,6 @@ use once_cell::sync::Lazy;
|
||||
pub mod cli;
|
||||
pub mod commands;
|
||||
pub mod logging;
|
||||
pub mod project_tracker;
|
||||
pub mod recipes;
|
||||
pub mod scenario_tests;
|
||||
pub mod session;
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use etcetera::{choose_app_strategy, AppStrategy};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Structure to track project information
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectInfo {
|
||||
/// The absolute path to the project directory
|
||||
pub path: String,
|
||||
/// Last time the project was accessed
|
||||
pub last_accessed: DateTime<Utc>,
|
||||
/// Last instruction sent to goose (if available)
|
||||
pub last_instruction: Option<String>,
|
||||
/// Last session ID associated with this project
|
||||
pub last_session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Structure to hold all tracked projects
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectTracker {
|
||||
projects: HashMap<String, ProjectInfo>,
|
||||
}
|
||||
|
||||
/// Project information with path as a separate field for easier access
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectInfoDisplay {
|
||||
/// The absolute path to the project directory
|
||||
pub path: String,
|
||||
/// Last time the project was accessed
|
||||
pub last_accessed: DateTime<Utc>,
|
||||
/// Last instruction sent to goose (if available)
|
||||
pub last_instruction: Option<String>,
|
||||
/// Last session ID associated with this project
|
||||
pub last_session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ProjectTracker {
|
||||
/// Get the path to the projects.json file
|
||||
fn get_projects_file() -> Result<PathBuf> {
|
||||
let projects_file = choose_app_strategy(crate::APP_STRATEGY.clone())
|
||||
.context("goose requires a home dir")?
|
||||
.in_data_dir("projects.json");
|
||||
|
||||
// Ensure data directory exists
|
||||
if let Some(parent) = projects_file.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(projects_file)
|
||||
}
|
||||
|
||||
/// Load the project tracker from the projects.json file
|
||||
pub fn load() -> Result<Self> {
|
||||
let projects_file = Self::get_projects_file()?;
|
||||
|
||||
if projects_file.exists() {
|
||||
let file_content = fs::read_to_string(&projects_file)?;
|
||||
let tracker: ProjectTracker = serde_json::from_str(&file_content)
|
||||
.context("Failed to parse projects.json file")?;
|
||||
Ok(tracker)
|
||||
} else {
|
||||
// If the file doesn't exist, create a new empty tracker
|
||||
Ok(ProjectTracker {
|
||||
projects: HashMap::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the project tracker to the projects.json file
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let projects_file = Self::get_projects_file()?;
|
||||
let json = serde_json::to_string_pretty(self)?;
|
||||
fs::write(projects_file, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update project information for the current directory
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `project_dir` - The project directory to update
|
||||
/// * `instruction` - Optional instruction that was sent to goose
|
||||
/// * `session_id` - Optional session ID associated with this project
|
||||
pub fn update_project(
|
||||
&mut self,
|
||||
project_dir: &Path,
|
||||
instruction: Option<&str>,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let dir_str = project_dir.to_string_lossy().to_string();
|
||||
|
||||
// Create or update the project entry
|
||||
let project_info = self.projects.entry(dir_str.clone()).or_insert(ProjectInfo {
|
||||
path: dir_str,
|
||||
last_accessed: Utc::now(),
|
||||
last_instruction: None,
|
||||
last_session_id: None,
|
||||
});
|
||||
|
||||
// Update the last accessed time
|
||||
project_info.last_accessed = Utc::now();
|
||||
|
||||
// Update the last instruction if provided
|
||||
if let Some(instr) = instruction {
|
||||
project_info.last_instruction = Some(instr.to_string());
|
||||
}
|
||||
|
||||
// Update the session ID if provided
|
||||
if let Some(id) = session_id {
|
||||
project_info.last_session_id = Some(id.to_string());
|
||||
}
|
||||
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// List all tracked projects
|
||||
///
|
||||
/// Returns a vector of ProjectInfoDisplay objects
|
||||
pub fn list_projects(&self) -> Vec<ProjectInfoDisplay> {
|
||||
self.projects
|
||||
.values()
|
||||
.map(|info| ProjectInfoDisplay {
|
||||
path: info.path.clone(),
|
||||
last_accessed: info.last_accessed,
|
||||
last_instruction: info.last_instruction.clone(),
|
||||
last_session_id: info.last_session_id.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the project tracker with the current directory and optional instruction
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `instruction` - Optional instruction that was sent to goose
|
||||
/// * `session_id` - Optional session ID associated with this project
|
||||
pub fn update_project_tracker(instruction: Option<&str>, session_id: Option<&str>) -> Result<()> {
|
||||
let current_dir = std::env::current_dir()?;
|
||||
let mut tracker = ProjectTracker::load()?;
|
||||
tracker.update_project(¤t_dir, instruction, session_id)
|
||||
}
|
||||
@@ -370,7 +370,6 @@ impl Session {
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<()> {
|
||||
let cancel_token = cancel_token.clone();
|
||||
let message_text = message.as_concat_text();
|
||||
|
||||
self.push_message(message);
|
||||
// Get the provider from the agent for description generation
|
||||
@@ -392,24 +391,6 @@ impl Session {
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Track the current directory and last instruction in projects.json
|
||||
let session_id = self
|
||||
.session_file
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_stem())
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Err(e) = crate::project_tracker::update_project_tracker(
|
||||
Some(&message_text),
|
||||
session_id.as_deref(),
|
||||
) {
|
||||
eprintln!(
|
||||
"Warning: Failed to update project tracker with instruction: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
self.process_agent_response(false, cancel_token).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -488,21 +469,6 @@ impl Session {
|
||||
|
||||
self.push_message(Message::user().with_text(&content));
|
||||
|
||||
// Track the current directory and last instruction in projects.json
|
||||
let session_id = self
|
||||
.session_file
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_stem())
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Err(e) = crate::project_tracker::update_project_tracker(
|
||||
Some(&content),
|
||||
session_id.as_deref(),
|
||||
) {
|
||||
eprintln!("Warning: Failed to update project tracker with instruction: {}", e);
|
||||
}
|
||||
|
||||
let provider = self.agent.provider().await?;
|
||||
|
||||
// Persist messages with provider for automatic description generation
|
||||
|
||||
@@ -5,7 +5,6 @@ pub mod config_management;
|
||||
pub mod context;
|
||||
pub mod extension;
|
||||
pub mod health;
|
||||
pub mod project;
|
||||
pub mod recipe;
|
||||
pub mod reply;
|
||||
pub mod schedule;
|
||||
@@ -29,6 +28,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
|
||||
.merge(recipe::routes(state.clone()))
|
||||
.merge(session::routes(state.clone()))
|
||||
.merge(schedule::routes(state.clone()))
|
||||
.merge(project::routes(state.clone()))
|
||||
.merge(setup::routes(state.clone()))
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
use super::utils::verify_secret_key;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use goose::project::{Project, ProjectMetadata};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateProjectRequest {
|
||||
/// Display name of the project
|
||||
pub name: String,
|
||||
/// Optional description of the project
|
||||
pub description: Option<String>,
|
||||
/// Default working directory for sessions in this project
|
||||
#[schema(value_type = String)]
|
||||
pub default_directory: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateProjectRequest {
|
||||
/// Display name of the project
|
||||
pub name: Option<String>,
|
||||
/// Optional description of the project
|
||||
pub description: Option<Option<String>>,
|
||||
/// Default working directory for sessions in this project
|
||||
#[schema(value_type = String)]
|
||||
pub default_directory: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectListResponse {
|
||||
/// List of available project metadata objects
|
||||
pub projects: Vec<ProjectMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectResponse {
|
||||
/// Project details
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/projects",
|
||||
responses(
|
||||
(status = 200, description = "List of available projects retrieved successfully", body = ProjectListResponse),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Project Management"
|
||||
)]
|
||||
// List all available projects
|
||||
async fn list_projects(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<ProjectListResponse>, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
let projects =
|
||||
goose::project::list_projects().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(ProjectListResponse { projects }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/projects/{project_id}",
|
||||
params(
|
||||
("project_id" = String, Path, description = "Unique identifier for the project")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Project details retrieved successfully", body = ProjectResponse),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 404, description = "Project not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Project Management"
|
||||
)]
|
||||
// Get a specific project details
|
||||
async fn get_project_details(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(project_id): Path<String>,
|
||||
) -> Result<Json<ProjectResponse>, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
let project = goose::project::get_project(&project_id).map_err(|e| {
|
||||
if e.to_string().contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(ProjectResponse { project }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/projects",
|
||||
request_body = CreateProjectRequest,
|
||||
responses(
|
||||
(status = 201, description = "Project created successfully", body = ProjectResponse),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 400, description = "Invalid request - Bad input parameters"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Project Management"
|
||||
)]
|
||||
// Create a new project
|
||||
async fn create_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(create_req): Json<CreateProjectRequest>,
|
||||
) -> Result<Json<ProjectResponse>, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
// Validate input
|
||||
if create_req.name.trim().is_empty() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let project = goose::project::create_project(
|
||||
create_req.name,
|
||||
create_req.description,
|
||||
create_req.default_directory,
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(ProjectResponse { project }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/projects/{project_id}",
|
||||
params(
|
||||
("project_id" = String, Path, description = "Unique identifier for the project")
|
||||
),
|
||||
request_body = UpdateProjectRequest,
|
||||
responses(
|
||||
(status = 200, description = "Project updated successfully", body = ProjectResponse),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 404, description = "Project not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Project Management"
|
||||
)]
|
||||
// Update a project
|
||||
async fn update_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(project_id): Path<String>,
|
||||
Json(update_req): Json<UpdateProjectRequest>,
|
||||
) -> Result<Json<ProjectResponse>, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
let project = goose::project::update_project(
|
||||
&project_id,
|
||||
update_req.name,
|
||||
update_req.description,
|
||||
update_req.default_directory,
|
||||
)
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(ProjectResponse { project }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/projects/{project_id}",
|
||||
params(
|
||||
("project_id" = String, Path, description = "Unique identifier for the project")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Project deleted successfully"),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 404, description = "Project not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Project Management"
|
||||
)]
|
||||
// Delete a project
|
||||
async fn delete_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(project_id): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
goose::project::delete_project(&project_id).map_err(|e| {
|
||||
if e.to_string().contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/projects/{project_id}/sessions/{session_id}",
|
||||
params(
|
||||
("project_id" = String, Path, description = "Unique identifier for the project"),
|
||||
("session_id" = String, Path, description = "Unique identifier for the session to add")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Session added to project successfully"),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 404, description = "Project or session not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Project Management"
|
||||
)]
|
||||
// Add session to project
|
||||
async fn add_session_to_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path((project_id, session_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
// Add the session to project
|
||||
goose::project::add_session_to_project(&project_id, &session_id).map_err(|e| {
|
||||
if e.to_string().contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
|
||||
// Also update session metadata to include the project_id
|
||||
let session_path =
|
||||
goose::session::get_path(goose::session::Identifier::Name(session_id.clone()))
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
let mut metadata =
|
||||
goose::session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
metadata.project_id = Some(project_id);
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
if let Err(e) = goose::session::update_metadata(&session_path, &metadata).await {
|
||||
tracing::error!("Failed to update session metadata: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/projects/{project_id}/sessions/{session_id}",
|
||||
params(
|
||||
("project_id" = String, Path, description = "Unique identifier for the project"),
|
||||
("session_id" = String, Path, description = "Unique identifier for the session to remove")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Session removed from project successfully"),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||
(status = 404, description = "Project or session not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("api_key" = [])
|
||||
),
|
||||
tag = "Project Management"
|
||||
)]
|
||||
// Remove session from project
|
||||
async fn remove_session_from_project(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path((project_id, session_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
verify_secret_key(&headers, &state)?;
|
||||
|
||||
// Remove from project
|
||||
goose::project::remove_session_from_project(&project_id, &session_id).map_err(|e| {
|
||||
if e.to_string().contains("not found") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
|
||||
// Also update session metadata to remove the project_id
|
||||
let session_path =
|
||||
goose::session::get_path(goose::session::Identifier::Name(session_id.clone()))
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
let mut metadata =
|
||||
goose::session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Only update if this session was actually in this project
|
||||
if metadata.project_id.as_deref() == Some(&project_id) {
|
||||
metadata.project_id = None;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
if let Err(e) = goose::session::update_metadata(&session_path, &metadata).await {
|
||||
tracing::error!("Failed to update session metadata: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// Configure routes for this module
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/projects", get(list_projects))
|
||||
.route("/projects", post(create_project))
|
||||
.route("/projects/{project_id}", get(get_project_details))
|
||||
.route("/projects/{project_id}", put(update_project))
|
||||
.route("/projects/{project_id}", delete(delete_project))
|
||||
.route(
|
||||
"/projects/{project_id}/sessions/{session_id}",
|
||||
post(add_session_to_project),
|
||||
)
|
||||
.route(
|
||||
"/projects/{project_id}/sessions/{session_id}",
|
||||
delete(remove_session_from_project),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -262,7 +262,6 @@ mod tests {
|
||||
working_dir: PathBuf::from(working_dir),
|
||||
description: "Test session".to_string(),
|
||||
schedule_id: Some("test_job".to_string()),
|
||||
project_id: None,
|
||||
total_tokens: Some(100),
|
||||
input_tokens: Some(50),
|
||||
output_tokens: Some(50),
|
||||
@@ -651,7 +650,6 @@ mod tests {
|
||||
comprehensive_metadata.schedule_id,
|
||||
Some("test_job".to_string())
|
||||
);
|
||||
assert!(comprehensive_metadata.project_id.is_none());
|
||||
assert_eq!(comprehensive_metadata.total_tokens, Some(100));
|
||||
assert_eq!(comprehensive_metadata.input_tokens, Some(50));
|
||||
assert_eq!(comprehensive_metadata.output_tokens, Some(50));
|
||||
|
||||
@@ -5,7 +5,6 @@ pub mod conversation;
|
||||
pub mod model;
|
||||
pub mod oauth;
|
||||
pub mod permission;
|
||||
pub mod project;
|
||||
pub mod prompt_template;
|
||||
pub mod providers;
|
||||
pub mod recipe;
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
pub mod storage;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Main project structure that holds project metadata and associated sessions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Project {
|
||||
/// Unique identifier for the project
|
||||
pub id: String,
|
||||
/// Display name of the project
|
||||
pub name: String,
|
||||
/// Optional description of the project
|
||||
pub description: Option<String>,
|
||||
/// Default working directory for sessions in this project
|
||||
#[schema(value_type = String, example = "/home/user/projects/my-project")]
|
||||
pub default_directory: PathBuf,
|
||||
/// When the project was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When the project was last updated
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// List of session IDs associated with this project
|
||||
pub session_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Simplified project metadata for listing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectMetadata {
|
||||
/// Unique identifier for the project
|
||||
pub id: String,
|
||||
/// Display name of the project
|
||||
pub name: String,
|
||||
/// Optional description of the project
|
||||
pub description: Option<String>,
|
||||
/// Default working directory for sessions in this project
|
||||
#[schema(value_type = String)]
|
||||
pub default_directory: PathBuf,
|
||||
/// Number of sessions in this project
|
||||
pub session_count: usize,
|
||||
/// When the project was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When the project was last updated
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<&Project> for ProjectMetadata {
|
||||
fn from(project: &Project) -> Self {
|
||||
ProjectMetadata {
|
||||
id: project.id.clone(),
|
||||
name: project.name.clone(),
|
||||
description: project.description.clone(),
|
||||
default_directory: project.default_directory.clone(),
|
||||
session_count: project.session_ids.len(),
|
||||
created_at: project.created_at,
|
||||
updated_at: project.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export storage functions
|
||||
pub use storage::{
|
||||
add_session_to_project, create_project, delete_project, ensure_project_dir, get_project,
|
||||
list_projects, remove_session_from_project, update_project,
|
||||
};
|
||||
@@ -1,239 +0,0 @@
|
||||
use crate::project::{Project, ProjectMetadata};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::Utc;
|
||||
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||
use serde_json;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
|
||||
const APP_NAME: &str = "goose";
|
||||
|
||||
/// Ensure the project directory exists and return its path
|
||||
pub fn ensure_project_dir() -> Result<PathBuf> {
|
||||
let app_strategy = AppStrategyArgs {
|
||||
top_level_domain: "Block".to_string(),
|
||||
author: "Block".to_string(),
|
||||
app_name: APP_NAME.to_string(),
|
||||
};
|
||||
|
||||
let data_dir = choose_app_strategy(app_strategy)
|
||||
.context("goose requires a home dir")?
|
||||
.data_dir()
|
||||
.join("projects");
|
||||
|
||||
if !data_dir.exists() {
|
||||
fs::create_dir_all(&data_dir)?;
|
||||
}
|
||||
|
||||
Ok(data_dir)
|
||||
}
|
||||
|
||||
/// Generate a unique project ID
|
||||
fn generate_project_id() -> String {
|
||||
use rand::Rng;
|
||||
let timestamp = Utc::now().timestamp();
|
||||
let random: u32 = rand::thread_rng().gen();
|
||||
format!("proj_{}_{}", timestamp, random)
|
||||
}
|
||||
|
||||
/// Get the path for a specific project file
|
||||
fn get_project_path(project_id: &str) -> Result<PathBuf> {
|
||||
let project_dir = ensure_project_dir()?;
|
||||
Ok(project_dir.join(format!("{}.json", project_id)))
|
||||
}
|
||||
|
||||
/// Create a new project
|
||||
pub fn create_project(
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
default_directory: PathBuf,
|
||||
) -> Result<Project> {
|
||||
let project_dir = ensure_project_dir()?;
|
||||
|
||||
// Validate the default directory exists
|
||||
if !default_directory.exists() {
|
||||
return Err(anyhow!(
|
||||
"Default directory does not exist: {:?}",
|
||||
default_directory
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let project = Project {
|
||||
id: generate_project_id(),
|
||||
name,
|
||||
description,
|
||||
default_directory,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
session_ids: Vec::new(),
|
||||
};
|
||||
|
||||
// Save the project
|
||||
let project_path = project_dir.join(format!("{}.json", project.id));
|
||||
let mut file = File::create(&project_path)?;
|
||||
let json = serde_json::to_string_pretty(&project)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
|
||||
info!("Created project {} at {:?}", project.id, project_path);
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Update an existing project
|
||||
pub fn update_project(
|
||||
project_id: &str,
|
||||
name: Option<String>,
|
||||
description: Option<Option<String>>,
|
||||
default_directory: Option<PathBuf>,
|
||||
) -> Result<Project> {
|
||||
let project_path = get_project_path(project_id)?;
|
||||
|
||||
if !project_path.exists() {
|
||||
return Err(anyhow!("Project not found: {}", project_id));
|
||||
}
|
||||
|
||||
// Read existing project
|
||||
let mut project: Project = serde_json::from_reader(File::open(&project_path)?)?;
|
||||
|
||||
// Update fields
|
||||
if let Some(new_name) = name {
|
||||
project.name = new_name;
|
||||
}
|
||||
|
||||
if let Some(new_description) = description {
|
||||
project.description = new_description;
|
||||
}
|
||||
|
||||
if let Some(new_directory) = default_directory {
|
||||
if !new_directory.exists() {
|
||||
return Err(anyhow!(
|
||||
"Default directory does not exist: {:?}",
|
||||
new_directory
|
||||
));
|
||||
}
|
||||
project.default_directory = new_directory;
|
||||
}
|
||||
|
||||
project.updated_at = Utc::now();
|
||||
|
||||
// Save updated project
|
||||
let mut file = File::create(&project_path)?;
|
||||
let json = serde_json::to_string_pretty(&project)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
|
||||
info!("Updated project {}", project_id);
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Delete a project (does not delete associated sessions)
|
||||
pub fn delete_project(project_id: &str) -> Result<()> {
|
||||
let project_path = get_project_path(project_id)?;
|
||||
|
||||
if !project_path.exists() {
|
||||
return Err(anyhow!("Project not found: {}", project_id));
|
||||
}
|
||||
|
||||
fs::remove_file(&project_path)?;
|
||||
info!("Deleted project {}", project_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all projects
|
||||
pub fn list_projects() -> Result<Vec<ProjectMetadata>> {
|
||||
let project_dir = ensure_project_dir()?;
|
||||
let mut projects = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&project_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
match serde_json::from_reader::<_, Project>(File::open(&path)?) {
|
||||
Ok(project) => {
|
||||
projects.push(ProjectMetadata::from(&project));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to read project file {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updated_at descending
|
||||
projects.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
/// Get a specific project
|
||||
pub fn get_project(project_id: &str) -> Result<Project> {
|
||||
let project_path = get_project_path(project_id)?;
|
||||
|
||||
if !project_path.exists() {
|
||||
return Err(anyhow!("Project not found: {}", project_id));
|
||||
}
|
||||
|
||||
let project: Project = serde_json::from_reader(File::open(&project_path)?)?;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Add a session to a project
|
||||
pub fn add_session_to_project(project_id: &str, session_id: &str) -> Result<()> {
|
||||
let project_path = get_project_path(project_id)?;
|
||||
|
||||
if !project_path.exists() {
|
||||
return Err(anyhow!("Project not found: {}", project_id));
|
||||
}
|
||||
|
||||
// Read project
|
||||
let mut project: Project = serde_json::from_reader(File::open(&project_path)?)?;
|
||||
|
||||
// Check if session already exists in project
|
||||
if project.session_ids.contains(&session_id.to_string()) {
|
||||
return Ok(()); // Already added
|
||||
}
|
||||
|
||||
// Add session and update timestamp
|
||||
project.session_ids.push(session_id.to_string());
|
||||
project.updated_at = Utc::now();
|
||||
|
||||
// Save updated project
|
||||
let mut file = File::create(&project_path)?;
|
||||
let json = serde_json::to_string_pretty(&project)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
|
||||
info!("Added session {} to project {}", session_id, project_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a session from a project
|
||||
pub fn remove_session_from_project(project_id: &str, session_id: &str) -> Result<()> {
|
||||
let project_path = get_project_path(project_id)?;
|
||||
|
||||
if !project_path.exists() {
|
||||
return Err(anyhow!("Project not found: {}", project_id));
|
||||
}
|
||||
|
||||
// Read project
|
||||
let mut project: Project = serde_json::from_reader(File::open(&project_path)?)?;
|
||||
|
||||
// Remove session
|
||||
let original_len = project.session_ids.len();
|
||||
project.session_ids.retain(|id| id != session_id);
|
||||
|
||||
if project.session_ids.len() == original_len {
|
||||
return Ok(()); // Session wasn't in project
|
||||
}
|
||||
|
||||
project.updated_at = Utc::now();
|
||||
|
||||
// Save updated project
|
||||
let mut file = File::create(&project_path)?;
|
||||
let json = serde_json::to_string_pretty(&project)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
|
||||
info!("Removed session {} from project {}", session_id, project_id);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1291,7 +1291,6 @@ async fn run_scheduled_job_internal(
|
||||
working_dir: current_dir.clone(),
|
||||
description: String::new(),
|
||||
schedule_id: Some(job.id.clone()),
|
||||
project_id: None,
|
||||
message_count: all_session_messages.len(),
|
||||
total_tokens: None,
|
||||
input_tokens: None,
|
||||
|
||||
@@ -49,8 +49,7 @@ pub struct SessionMetadata {
|
||||
pub description: String,
|
||||
/// ID of the schedule that triggered this session, if any
|
||||
pub schedule_id: Option<String>,
|
||||
/// ID of the project this session belongs to, if any
|
||||
pub project_id: Option<String>,
|
||||
|
||||
/// Number of messages in the session
|
||||
pub message_count: usize,
|
||||
/// The total number of tokens used in the session. Retrieved from the provider's last usage.
|
||||
@@ -78,7 +77,6 @@ impl<'de> Deserialize<'de> for SessionMetadata {
|
||||
description: String,
|
||||
message_count: usize,
|
||||
schedule_id: Option<String>, // For backward compatibility
|
||||
project_id: Option<String>, // For backward compatibility
|
||||
total_tokens: Option<i32>,
|
||||
input_tokens: Option<i32>,
|
||||
output_tokens: Option<i32>,
|
||||
@@ -100,7 +98,6 @@ impl<'de> Deserialize<'de> for SessionMetadata {
|
||||
description: helper.description,
|
||||
message_count: helper.message_count,
|
||||
schedule_id: helper.schedule_id,
|
||||
project_id: helper.project_id,
|
||||
total_tokens: helper.total_tokens,
|
||||
input_tokens: helper.input_tokens,
|
||||
output_tokens: helper.output_tokens,
|
||||
@@ -125,7 +122,6 @@ impl SessionMetadata {
|
||||
working_dir,
|
||||
description: String::new(),
|
||||
schedule_id: None,
|
||||
project_id: None,
|
||||
message_count: 0,
|
||||
total_tokens: None,
|
||||
input_tokens: None,
|
||||
|
||||
@@ -405,7 +405,6 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) ->
|
||||
working_dir: PathBuf::from(working_dir),
|
||||
description: "Test session".to_string(),
|
||||
schedule_id: Some("test_job".to_string()),
|
||||
project_id: None,
|
||||
total_tokens: Some(100),
|
||||
input_tokens: Some(50),
|
||||
output_tokens: Some(50),
|
||||
|
||||
@@ -2987,11 +2987,6 @@
|
||||
"description": "The number of output tokens used in the session. Retrieved from the provider's last usage.",
|
||||
"nullable": true
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the project this session belongs to, if any",
|
||||
"nullable": true
|
||||
},
|
||||
"schedule_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the schedule that triggered this session, if any",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Paths
|
||||
const platformWinDir = path.join(__dirname, '..', 'src', 'platform', 'windows', 'bin');
|
||||
const outDir = path.join(__dirname, '..', 'out', 'Goose-win32-x64', 'resources', 'bin');
|
||||
const srcBinDir = path.join(__dirname, '..', 'src', 'bin');
|
||||
|
||||
// Helper function to copy files
|
||||
function copyFiles(sourceDir, targetDir) {
|
||||
// Ensure target directory exists
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy all files from source to target directory
|
||||
console.log(`Copying files to ${targetDir}...`);
|
||||
fs.readdirSync(sourceDir).forEach(file => {
|
||||
const srcPath = path.join(sourceDir, file);
|
||||
const destPath = path.join(targetDir, file);
|
||||
|
||||
// Skip .gitignore and README.md
|
||||
if (file === '.gitignore' || file === 'README.md') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle directories (like goose-npm)
|
||||
if (fs.statSync(srcPath).isDirectory()) {
|
||||
fs.cpSync(srcPath, destPath, { recursive: true, force: true });
|
||||
console.log(`Copied directory: ${file}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy individual file
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
console.log(`Copied: ${file}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Copy to both directories
|
||||
console.log('Copying Windows-specific files...');
|
||||
|
||||
// Copy to src/bin for development
|
||||
copyFiles(platformWinDir, srcBinDir);
|
||||
|
||||
// Copy to output directory for distribution
|
||||
copyFiles(platformWinDir, outDir);
|
||||
|
||||
console.log('Windows-specific files copied successfully');
|
||||
@@ -1,53 +0,0 @@
|
||||
const { createCanvas, loadImage } = require('canvas');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function generateUpdateIcon() {
|
||||
// Load the original icon
|
||||
const iconPath = path.join(__dirname, '../src/images/iconTemplate.png');
|
||||
const icon = await loadImage(iconPath);
|
||||
|
||||
// Create canvas
|
||||
const canvas = createCanvas(22, 22);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw the original icon
|
||||
ctx.drawImage(icon, 0, 0);
|
||||
|
||||
// Add red dot in top-right corner
|
||||
ctx.fillStyle = '#FF0000';
|
||||
ctx.beginPath();
|
||||
ctx.arc(18, 4, 3, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// Save the new icon
|
||||
const outputPath = path.join(__dirname, '../src/images/iconTemplateUpdate.png');
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
fs.writeFileSync(outputPath, buffer);
|
||||
|
||||
console.log('Generated update icon at:', outputPath);
|
||||
|
||||
// Also generate @2x version
|
||||
const canvas2x = createCanvas(44, 44);
|
||||
const ctx2x = canvas2x.getContext('2d');
|
||||
|
||||
// Load and draw @2x version
|
||||
const icon2xPath = path.join(__dirname, '../src/images/iconTemplate@2x.png');
|
||||
const icon2x = await loadImage(icon2xPath);
|
||||
ctx2x.drawImage(icon2x, 0, 0);
|
||||
|
||||
// Add red dot in top-right corner (scaled)
|
||||
ctx2x.fillStyle = '#FF0000';
|
||||
ctx2x.beginPath();
|
||||
ctx2x.arc(36, 8, 6, 0, 2 * Math.PI);
|
||||
ctx2x.fill();
|
||||
|
||||
// Save the @2x version
|
||||
const output2xPath = path.join(__dirname, '../src/images/iconTemplateUpdate@2x.png');
|
||||
const buffer2x = canvas2x.toBuffer('image/png');
|
||||
fs.writeFileSync(output2xPath, buffer2x);
|
||||
|
||||
console.log('Generated @2x update icon at:', output2xPath);
|
||||
}
|
||||
|
||||
generateUpdateIcon().catch(console.error);
|
||||
@@ -1,16 +0,0 @@
|
||||
const { execSync } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
execSync(path.join(__dirname, 'prepare-windows-npm.bat'), { stdio: 'inherit' });
|
||||
} else {
|
||||
execSync(path.join(__dirname, 'prepare-windows-npm.sh'), {
|
||||
stdio: 'inherit',
|
||||
shell: '/bin/bash'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preparing platform:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import { COST_TRACKING_ENABLED } from './updates';
|
||||
|
||||
import { type SessionDetails } from './sessions';
|
||||
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
|
||||
// import ProjectsContainer from './components/projects/ProjectsContainer';
|
||||
import { Recipe } from './recipe';
|
||||
import RecipesView from './components/RecipesView';
|
||||
import RecipeEditor from './components/RecipeEditor';
|
||||
@@ -67,7 +66,6 @@ export type View =
|
||||
| 'recipeEditor'
|
||||
| 'recipes'
|
||||
| 'permission';
|
||||
// | 'projects';
|
||||
|
||||
export type ViewOptions = {
|
||||
// Settings view options
|
||||
@@ -651,57 +649,6 @@ const ExtensionsRoute = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// const ProjectsRoute = () => {
|
||||
// const navigate = useNavigate();
|
||||
//
|
||||
// const setView = (view: View, viewOptions?: ViewOptions) => {
|
||||
// // Convert view to route navigation
|
||||
// switch (view) {
|
||||
// case 'chat':
|
||||
// navigate('/');
|
||||
// break;
|
||||
// case 'pair':
|
||||
// navigate('/pair', { state: viewOptions });
|
||||
// break;
|
||||
// case 'settings':
|
||||
// navigate('/settings', { state: viewOptions });
|
||||
// break;
|
||||
// case 'sessions':
|
||||
// navigate('/sessions');
|
||||
// break;
|
||||
// case 'schedules':
|
||||
// navigate('/schedules');
|
||||
// break;
|
||||
// case 'recipes':
|
||||
// navigate('/recipes');
|
||||
// break;
|
||||
// case 'permission':
|
||||
// navigate('/permission', { state: viewOptions });
|
||||
// break;
|
||||
// case 'ConfigureProviders':
|
||||
// navigate('/configure-providers');
|
||||
// break;
|
||||
// case 'sharedSession':
|
||||
// navigate('/shared-session', { state: viewOptions });
|
||||
// break;
|
||||
// case 'recipeEditor':
|
||||
// navigate('/recipe-editor', { state: viewOptions });
|
||||
// break;
|
||||
// case 'welcome':
|
||||
// navigate('/welcome');
|
||||
// break;
|
||||
// default:
|
||||
// navigate('/');
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// return (
|
||||
// <React.Suspense fallback={<div>Loading projects...</div>}>
|
||||
// <ProjectsContainer setView={setView} />
|
||||
// </React.Suspense>
|
||||
// );
|
||||
// };
|
||||
|
||||
export default function App() {
|
||||
const [fatalError, setFatalError] = useState<string | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -1539,16 +1486,6 @@ export default function App() {
|
||||
</ProviderGuard>
|
||||
}
|
||||
/>
|
||||
{/*<Route*/}
|
||||
{/* path="projects"*/}
|
||||
{/* element={*/}
|
||||
{/* <ProviderGuard>*/}
|
||||
{/* <ChatProvider chat={chat} setChat={setChat}>*/}
|
||||
{/* <ProjectsRoute />*/}
|
||||
{/* </ChatProvider>*/}
|
||||
{/* </ProviderGuard> */}
|
||||
{/* }*/}
|
||||
{/*/>*/}
|
||||
</Route>
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -669,10 +669,6 @@ export type SessionMetadata = {
|
||||
* The number of output tokens used in the session. Retrieved from the provider's last usage.
|
||||
*/
|
||||
output_tokens?: number | null;
|
||||
/**
|
||||
* ID of the project this session belongs to, if any
|
||||
*/
|
||||
project_id?: string | null;
|
||||
/**
|
||||
* ID of the schedule that triggered this session, if any
|
||||
*/
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Bird } from './ui/icons';
|
||||
import { ChevronDown } from './icons';
|
||||
|
||||
interface ApiKeyWarningProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function Collapsible({ title, children, defaultOpen = false }: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg mb-2">
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left flex justify-between items-center hover:bg-gray-50"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="font-medium">{title}</span>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && <div className="px-4 py-2 border-t">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const OPENAI_CONFIG = `export GOOSE_PROVIDER__TYPE=openai
|
||||
export GOOSE_PROVIDER__HOST=https://api.openai.com
|
||||
export GOOSE_PROVIDER__MODEL=gpt-4o
|
||||
export GOOSE_PROVIDER__API_KEY=your_api_key_here`;
|
||||
|
||||
const ANTHROPIC_CONFIG = `export GOOSE_PROVIDER__TYPE=anthropic
|
||||
export GOOSE_PROVIDER__HOST=https://api.anthropic.com
|
||||
export GOOSE_PROVIDER__MODEL=claude-3-5-sonnet-latest
|
||||
export GOOSE_PROVIDER__API_KEY=your_api_key_here`;
|
||||
|
||||
const DATABRICKS_CONFIG = `export GOOSE_PROVIDER__TYPE=databricks
|
||||
export GOOSE_PROVIDER__HOST=your_databricks_host
|
||||
export GOOSE_PROVIDER__MODEL=your_databricks_model`;
|
||||
|
||||
const OPENROUTER_CONFIG = `export GOOSE_PROVIDER__TYPE=openrouter
|
||||
export GOOSE_PROVIDER__HOST=https://openrouter.ai
|
||||
export GOOSE_PROVIDER__MODEL=anthropic/claude-3.5-sonnet
|
||||
export GOOSE_PROVIDER__API_KEY=your_api_key_here`;
|
||||
|
||||
export function ApiKeyWarning({ className }: ApiKeyWarningProps) {
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col items-center p-8 space-y-6 bg-card-gradient w-full h-full ${className}`}
|
||||
>
|
||||
<div className="w-16 h-16">
|
||||
<Bird />
|
||||
</div>
|
||||
<div className="text-center space-y-4 max-w-2xl w-full">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Credentials Required</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
To use Goose, you need to set environment variables for one of the following providers:
|
||||
</p>
|
||||
|
||||
<div className="text-left">
|
||||
<Collapsible title="OpenAI Configuration" defaultOpen={true}>
|
||||
<pre className="bg-gray-50 p-4 rounded-md text-sm">{OPENAI_CONFIG}</pre>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Anthropic (Claude) Configuration">
|
||||
<pre className="bg-gray-50 p-4 rounded-md text-sm">{ANTHROPIC_CONFIG}</pre>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Databricks Configuration">
|
||||
<pre className="bg-gray-50 p-4 rounded-md text-sm">{DATABRICKS_CONFIG}</pre>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="OpenRouter Configuration">
|
||||
<pre className="bg-gray-50 p-4 rounded-md text-sm">{OPENROUTER_CONFIG}</pre>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-4">
|
||||
After setting these variables, restart Goose for the changes to take effect.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export function LoadingPlaceholder() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[...Array(2)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 bg-gradient-to-r from-[#ffffff]/30 via-[#7A7EFB]/40 to-[#ffffff]/30
|
||||
animate-shimmer-pulse bg-[length:200%_100%] rounded-full"
|
||||
style={{
|
||||
width: `${Math.floor(Math.random() * (100 - 70 + 1) + 70)}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
|
||||
interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode; // Optional footer
|
||||
onClose: () => void; // Function to call when modal should close
|
||||
preventBackdropClose?: boolean; // Optional prop to prevent closing on backdrop click
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable modal component that renders content with a semi-transparent backdrop and blur effect.
|
||||
* Closes when clicking outside the modal or pressing Esc key.
|
||||
*/
|
||||
export default function Modal({
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
preventBackdropClose = false,
|
||||
}: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside the modal content
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (preventBackdropClose) return;
|
||||
// Check if the click was on the backdrop and not on the modal content
|
||||
// Also check if the click target is not part of a Select menu
|
||||
if (
|
||||
modalRef.current &&
|
||||
!modalRef.current.contains(e.target as Node) &&
|
||||
!(e.target as HTMLElement).closest('.select__menu') &&
|
||||
window.getSelection()?.toString().length === 0 // Ensure no text is selected
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Esc key press
|
||||
useEffect(() => {
|
||||
const handleEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Don't close if a select menu is open
|
||||
const selectMenu = document.querySelector('.select__menu');
|
||||
if (!selectMenu) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener for Escape key
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
|
||||
// Add overflow-hidden to body to prevent scrolling background
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscKey);
|
||||
// Restore body scrolling when modal closes
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 dark:bg-white/20 transition-colors animate-[fadein_200ms_ease-in_forwards] flex items-center justify-center p-4 z-[9999]"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ isolation: 'isolate' }} /* Creates a new stacking context */
|
||||
>
|
||||
<Card
|
||||
ref={modalRef}
|
||||
className="relative w-[500px] max-w-full bg-background-default rounded-xl my-10 max-h-[90vh] flex flex-col shadow-xl z-[10000]"
|
||||
>
|
||||
<div className="p-8 max-h-[calc(90vh-180px)] overflow-y-auto">{children}</div>
|
||||
{footer && (
|
||||
<div className="border-t border-borderSubtle bg-background-default w-full rounded-b-xl overflow-hidden">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
|
||||
function truncateText(text: string, maxLength: number = 100): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
interface SplashPillProps {
|
||||
content: string;
|
||||
append: (text: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SplashPill({ content, append, className = '' }: SplashPillProps) {
|
||||
const displayText = truncateText(content);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-2 text-sm text-center text-textStandard cursor-pointer border border-borderSubtle hover:bg-bgSubtle rounded-full transition-all duration-150 ${className}`}
|
||||
onClick={async () => {
|
||||
// Always use the full text (longForm or original content) when clicked
|
||||
await append(content);
|
||||
}}
|
||||
title={content.length > 100 ? content : undefined} // Show full text on hover if truncated
|
||||
>
|
||||
<div className="whitespace-normal">{displayText}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContextBlockProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
function ContextBlock({ content }: ContextBlockProps) {
|
||||
// Remove the "message:" prefix and trim whitespace
|
||||
const displayText = content.replace(/^message:/i, '').trim();
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-bgSubtle rounded-lg border border-borderStandard animate-[fadein_500ms_ease-in_forwards]">
|
||||
<MarkdownContent content={displayText} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SplashPillsProps {
|
||||
append: (text: string) => void;
|
||||
activities: string[] | null;
|
||||
}
|
||||
|
||||
export default function SplashPills({ append, activities = null }: SplashPillsProps) {
|
||||
// If custom activities are provided, use those instead of the default ones
|
||||
const defaultPills = [
|
||||
'What can you do?',
|
||||
'Demo writing and reading files',
|
||||
'Make a snake game in a new folder',
|
||||
'List files in my current directory',
|
||||
'Take a screenshot and summarize',
|
||||
];
|
||||
|
||||
const pills = activities || defaultPills;
|
||||
|
||||
// Find any pill that starts with "message:"
|
||||
const messagePillIndex = pills.findIndex((pill) => pill.toLowerCase().startsWith('message:'));
|
||||
|
||||
// Extract the message pill and the remaining pills
|
||||
const messagePill = messagePillIndex >= 0 ? pills[messagePillIndex] : null;
|
||||
const remainingPills =
|
||||
messagePillIndex >= 0
|
||||
? [...pills.slice(0, messagePillIndex), ...pills.slice(messagePillIndex + 1)]
|
||||
: pills;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{messagePill && <ContextBlock content={messagePill} />}
|
||||
|
||||
<div className="flex flex-wrap gap-2 animate-[fadein_500ms_ease-in_forwards]">
|
||||
{remainingPills.map((content, index) => (
|
||||
<SplashPill key={index} content={content} append={append} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CLIChatViewProps {
|
||||
sessionId: string;
|
||||
// onSessionExit: () => void;
|
||||
}
|
||||
|
||||
export const CLIChatView: React.FC<CLIChatViewProps> = ({ sessionId }) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 p-4">
|
||||
<p className="text-muted-foreground">CLI Chat View for session: {sessionId}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">This component is under development.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CLIChatView } from './CLIChatView';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Terminal, Settings, History, MessageSquare, ArrowLeft, RefreshCw } from 'lucide-react';
|
||||
import { generateSessionId } from '../../sessions';
|
||||
import { type View, ViewOptions } from '../../App';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
|
||||
interface ChatMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
id?: string;
|
||||
timestamp?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
id: string;
|
||||
title: string;
|
||||
messageHistoryIndex: number;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
interface CLIHubProps {
|
||||
chat: ChatState;
|
||||
setChat: (chat: ChatState) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
}
|
||||
|
||||
export const CLIHub: React.FC<CLIHubProps> = ({ chat, setChat, setView }) => {
|
||||
const navigate = useNavigate();
|
||||
const [sessionId, setSessionId] = useState(chat.id || generateSessionId());
|
||||
|
||||
// Update chat when session changes
|
||||
useEffect(() => {
|
||||
setChat({
|
||||
...chat,
|
||||
id: sessionId,
|
||||
title: `CLI Session - ${sessionId}`,
|
||||
messageHistoryIndex: 0,
|
||||
messages: [],
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, setChat]);
|
||||
|
||||
const handleNewSession = () => {
|
||||
const newSessionId = generateSessionId();
|
||||
setSessionId(newSessionId);
|
||||
};
|
||||
|
||||
const handleRestartSession = () => {
|
||||
// Force restart by changing session ID
|
||||
const newSessionId = generateSessionId();
|
||||
setSessionId(newSessionId);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
{/* Header */}
|
||||
<div className="h-12 flex items-center justify-between">
|
||||
<div className="flex items-center pr-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/')} className="mr-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Terminal className="w-5 h-5 mr-2" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Goose CLI Experience</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Exact CLI behavior with GUI enhancements
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
CLI Mode
|
||||
</Badge>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleNewSession}>
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
New Session
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleRestartSession}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Restart
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setView('settings')}>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CLI Chat View */}
|
||||
<div className="flex flex-col min-w-0 flex-1 overflow-y-auto relative pl-6 pr-4 pb-16 pt-2">
|
||||
<CLIChatView sessionId={sessionId} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="relative z-10 p-4 border-t bg-muted/30">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>Session: {sessionId}</span>
|
||||
<span>•</span>
|
||||
<span>CLI Mode Active</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setView('sessions')}>
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
Sessions
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => setView('settings')}>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
export function Bars() {
|
||||
return (
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="32" cy="32" r="32" fill="#101010" />
|
||||
<rect x="16" y="23" width="32" height="8" rx="4" fill="white" />
|
||||
<rect x="16" y="34" width="20" height="8" rx="4" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Session } from '../../sessions';
|
||||
import { Project } from '../../projects';
|
||||
import { addSessionToProject } from '../../projects';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface AddSessionToProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: Project;
|
||||
availableSessions: Session[];
|
||||
onSessionsAdded: () => void;
|
||||
}
|
||||
|
||||
const AddSessionToProjectModal: React.FC<AddSessionToProjectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
availableSessions,
|
||||
onSessionsAdded,
|
||||
}) => {
|
||||
const [selectedSessions, setSelectedSessions] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleToggleSession = (sessionId: string) => {
|
||||
setSelectedSessions((prev) => {
|
||||
if (prev.includes(sessionId)) {
|
||||
return prev.filter((id) => id !== sessionId);
|
||||
} else {
|
||||
return [...prev, sessionId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedSessions([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedSessions.length === 0) {
|
||||
toastError({ title: 'Error', msg: 'Please select at least one session' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Add each selected session to the project
|
||||
const promises = selectedSessions.map((sessionId) =>
|
||||
addSessionToProject(project.id, sessionId)
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
toastSuccess({
|
||||
title: 'Success',
|
||||
msg: `Added ${selectedSessions.length} ${selectedSessions.length === 1 ? 'session' : 'sessions'} to project`,
|
||||
});
|
||||
onSessionsAdded();
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to add sessions to project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to add sessions to project' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Sessions to Project</DialogTitle>
|
||||
<DialogDescription>Select sessions to add to "{project.name}"</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{availableSessions.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No available sessions to add. All sessions are already part of this project.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[300px] mt-4 pr-4">
|
||||
<div className="space-y-2">
|
||||
{availableSessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center space-x-3 py-2 px-3 rounded-md hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
id={`session-${session.id}`}
|
||||
checked={selectedSessions.includes(session.id)}
|
||||
onCheckedChange={() => handleToggleSession(session.id)}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<label
|
||||
htmlFor={`session-${session.id}`}
|
||||
className="text-sm font-medium leading-none cursor-pointer flex justify-between w-full"
|
||||
>
|
||||
<span className="truncate">{session.metadata.description}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(session.modified))} ago
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{session.metadata.working_dir}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={handleClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || selectedSessions.length === 0}>
|
||||
{isSubmitting ? 'Adding...' : 'Add Sessions'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSessionToProjectModal;
|
||||
@@ -1,151 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { FolderSearch } from 'lucide-react';
|
||||
import { toastError } from '../../toasts';
|
||||
|
||||
interface CreateProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, description: string, defaultDirectory: string) => void;
|
||||
defaultDirectory?: string;
|
||||
}
|
||||
|
||||
const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreate,
|
||||
defaultDirectory: defaultDirectoryProp,
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [defaultDirectory, setDefaultDirectory] = useState(defaultDirectoryProp || '');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDefaultDirectory(defaultDirectoryProp || '');
|
||||
}
|
||||
}, [defaultDirectoryProp, isOpen]);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setDefaultDirectory(defaultDirectoryProp || '');
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
toastError({ title: 'Error', msg: 'Project name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Pass data to parent component
|
||||
onCreate(name, description, defaultDirectory);
|
||||
|
||||
// Form will be reset when the modal is closed by the parent
|
||||
// after successful creation
|
||||
};
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
try {
|
||||
// Use Electron's dialog to pick a directory
|
||||
const directory = await window.electron.directoryChooser();
|
||||
|
||||
if (!directory.canceled && directory.filePaths.length > 0) {
|
||||
setDefaultDirectory(directory.filePaths[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pick directory:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to pick directory' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create new project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a project to group related sessions together
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name*</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Project"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="resize-none"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="directory">Directory</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="directory"
|
||||
value={defaultDirectory}
|
||||
onChange={(e) => setDefaultDirectory(e.target.value)}
|
||||
className="flex-grow"
|
||||
placeholder="Default working directory for sessions"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handlePickDirectory}>
|
||||
<FolderSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button variant="outline" onClick={handleClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProjectModal;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ProjectMetadata } from '../../projects';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../ui/card';
|
||||
import { Folder, Calendar } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectMetadata;
|
||||
onClick: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const ProjectCard: React.FC<ProjectCardProps> = ({ project, onClick }) => {
|
||||
return (
|
||||
<Card
|
||||
className="transition-all duration-200 hover:shadow-default hover:cursor-pointer min-h-[140px] flex flex-col"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Folder className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||
{project.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 text-sm flex-grow flex flex-col justify-between">
|
||||
{project.description && (
|
||||
<div className="mb-2">
|
||||
<span className="text-text-muted line-clamp-2">{project.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-text-muted mt-auto">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
<span>{formatDistanceToNow(new Date(project.updatedAt))} ago</span>
|
||||
</div>
|
||||
<span>
|
||||
{project.sessionCount} {project.sessionCount === 1 ? 'session' : 'sessions'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectCard;
|
||||
@@ -1,498 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Project } from '../../projects';
|
||||
import { Session, fetchSessions } from '../../sessions';
|
||||
import {
|
||||
getProject as fetchProject,
|
||||
removeSessionFromProject,
|
||||
deleteProject,
|
||||
addSessionToProject,
|
||||
} from '../../projects';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader,
|
||||
RefreshCcw,
|
||||
Edit,
|
||||
Trash2,
|
||||
Folder,
|
||||
MessageSquareText,
|
||||
ChevronLeft,
|
||||
LoaderCircle,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { formatMessageTimestamp } from '../../utils/timeUtils';
|
||||
import AddSessionToProjectModal from './AddSessionToProjectModal';
|
||||
import UpdateProjectModal from './UpdateProjectModal';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../ui/alert-dialog';
|
||||
import { ChatSmart } from '../icons';
|
||||
import { View, ViewOptions } from '../../App';
|
||||
import { Card } from '../ui/card';
|
||||
|
||||
interface ProjectDetailsViewProps {
|
||||
projectId: string;
|
||||
onBack: () => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
}
|
||||
|
||||
// Custom ProjectHeader component similar to SessionHistoryView style
|
||||
const ProjectHeader: React.FC<{
|
||||
onBack: () => void;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
actionButtons?: React.ReactNode;
|
||||
}> = ({ onBack, children, title, actionButtons }) => {
|
||||
return (
|
||||
<div className="flex flex-col pb-8">
|
||||
<div className="flex items-center pt-13 pb-2">
|
||||
<Button onClick={onBack} size="xs" variant="outline">
|
||||
<ChevronLeft />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-4xl font-light mb-4">{title}</h1>
|
||||
<div className="flex items-center">{children}</div>
|
||||
{actionButtons && <div className="flex items-center space-x-3 mt-4">{actionButtons}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// New component for displaying project sessions with consistent styling
|
||||
const ProjectSessions: React.FC<{
|
||||
sessions: Session[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
onRemoveSession: (sessionId: string) => void;
|
||||
onAddSession: () => void;
|
||||
}> = ({ sessions, isLoading, error, onRetry }) => {
|
||||
return (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="pb-16">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<LoaderCircle className="animate-spin h-8 w-8 text-textStandard" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
|
||||
<div className="text-red-500 mb-4">
|
||||
<AlertCircle size={32} />
|
||||
</div>
|
||||
<p className="text-md mb-2">Error Loading Project Details</p>
|
||||
<p className="text-sm text-center mb-4">{error}</p>
|
||||
<Button onClick={onRetry} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : sessions?.length > 0 ? (
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sessions.map((session) => (
|
||||
<Card
|
||||
key={session.id}
|
||||
className="h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base truncate mb-1">
|
||||
{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>
|
||||
{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>
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveSession(session.id);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Remove
|
||||
</Button> */}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col justify-center text-textSubtle">
|
||||
<p className="text-lg mb-2">No sessions in this project</p>
|
||||
<p className="text-sm mb-4 text-text-muted">
|
||||
Add sessions to this project to keep your work organized
|
||||
</p>
|
||||
{/* <Button onClick={onAddSession}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Session
|
||||
</Button> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectDetailsView: React.FC<ProjectDetailsViewProps> = ({ projectId, onBack, setView }) => {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAddSessionModalOpen, setIsAddSessionModalOpen] = useState(false);
|
||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadProjectData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch the project details
|
||||
const projectData = await fetchProject(projectId);
|
||||
setProject(projectData);
|
||||
|
||||
// Fetch all sessions
|
||||
const allSessionsData = await fetchSessions();
|
||||
setAllSessions(allSessionsData);
|
||||
|
||||
// Filter sessions that belong to this project
|
||||
const projectSessions = allSessionsData.filter((session: Session) =>
|
||||
projectData.sessionIds.includes(session.id)
|
||||
);
|
||||
|
||||
setSessions(projectSessions);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project data:', err);
|
||||
setError('Failed to load project data');
|
||||
toastError({ title: 'Error', msg: 'Failed to load project data' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch project details and associated sessions
|
||||
useEffect(() => {
|
||||
loadProjectData();
|
||||
}, [projectId, loadProjectData]);
|
||||
|
||||
// Set up session creation listener to automatically associate new sessions with this project
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
|
||||
const handleSessionCreated = async () => {
|
||||
console.log(
|
||||
'ProjectDetailsView: Session created event received, checking for new sessions...'
|
||||
);
|
||||
|
||||
// Wait a bit for the session to be fully created
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Fetch all sessions to find the newest one
|
||||
const allSessionsData = await fetchSessions();
|
||||
|
||||
// Find sessions that are not in this project but were created recently
|
||||
const recentSessions = allSessionsData.filter((session: Session) => {
|
||||
const sessionDate = new Date(session.modified);
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const isRecent = sessionDate > fiveMinutesAgo;
|
||||
const isNotInProject = !project.sessionIds.includes(session.id);
|
||||
const isInProjectDirectory = session.metadata.working_dir === project.defaultDirectory;
|
||||
|
||||
return isRecent && isNotInProject && isInProjectDirectory;
|
||||
});
|
||||
|
||||
// Add recent sessions to this project
|
||||
for (const session of recentSessions) {
|
||||
try {
|
||||
await addSessionToProject(project.id, session.id);
|
||||
console.log(`Automatically added session ${session.id} to project ${project.id}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to add session ${session.id} to project:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh project data if we added any sessions
|
||||
if (recentSessions.length > 0) {
|
||||
loadProjectData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking for new sessions:', err);
|
||||
}
|
||||
}, 2000); // Wait 2 seconds for session to be created
|
||||
};
|
||||
|
||||
// Listen for session creation events
|
||||
window.addEventListener('session-created', handleSessionCreated);
|
||||
window.addEventListener('message-stream-finished', handleSessionCreated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('session-created', handleSessionCreated);
|
||||
window.removeEventListener('message-stream-finished', handleSessionCreated);
|
||||
};
|
||||
}, [project, loadProjectData]);
|
||||
|
||||
const handleRemoveSession = async (sessionId: string) => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
await removeSessionFromProject(project.id, sessionId);
|
||||
|
||||
// Update local state
|
||||
setProject((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
sessionIds: prev.sessionIds.filter((id) => id !== sessionId),
|
||||
};
|
||||
});
|
||||
|
||||
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
||||
toastSuccess({ title: 'Success', msg: 'Session removed from project' });
|
||||
} catch (err) {
|
||||
console.error('Failed to remove session from project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to remove session from project' });
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionsNotInProject = () => {
|
||||
if (!project) return [];
|
||||
|
||||
return allSessions.filter((session) => !project.sessionIds.includes(session.id));
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!project) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteProject(project.id);
|
||||
toastSuccess({ title: 'Success', msg: `Project "${project.name}" deleted successfully` });
|
||||
onBack(); // Go back to projects list
|
||||
} catch (err) {
|
||||
console.error('Failed to delete project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to delete project' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
if (!project) return;
|
||||
|
||||
console.log(`Navigating to chat page for project: ${project.name}`);
|
||||
|
||||
// Update the working directory in localStorage to the project's directory
|
||||
try {
|
||||
const currentConfig = JSON.parse(localStorage.getItem('gooseConfig') || '{}');
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
GOOSE_WORKING_DIR: project.defaultDirectory,
|
||||
};
|
||||
localStorage.setItem('gooseConfig', JSON.stringify(updatedConfig));
|
||||
} catch (error) {
|
||||
console.error('Failed to update working directory in localStorage:', error);
|
||||
}
|
||||
|
||||
// Navigate to the pair page
|
||||
setView('pair');
|
||||
|
||||
toastSuccess({
|
||||
title: 'New Session',
|
||||
msg: `Starting new session in ${project.name}`,
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex flex-col h-full w-full items-center justify-center">
|
||||
<Loader className="h-10 w-10 animate-spin opacity-70 mb-4" />
|
||||
<p className="text-muted-foreground">Loading project...</p>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex flex-col h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error || 'Project not found'}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
||||
</Button>
|
||||
<Button onClick={loadProjectData}>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" /> Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Define action buttons
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Button onClick={handleNewSession} size="sm" className="flex items-center gap-1">
|
||||
<ChatSmart className="h-4 w-4" />
|
||||
<span>New session</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsUpdateModalOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</Button>
|
||||
{/* <Button
|
||||
onClick={() => setIsAddSessionModalOpen(true)}
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Session</span>
|
||||
</Button> */}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0 px-8">
|
||||
<ProjectHeader onBack={onBack} title={project.name} actionButtons={actionButtons}>
|
||||
<div className="flex flex-col">
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="flex items-center text-text-muted text-sm space-x-5 font-mono">
|
||||
<span className="flex items-center">
|
||||
<MessageSquareText className="w-4 h-4 mr-1" />
|
||||
{sessions.length} {sessions.length === 1 ? 'session' : 'sessions'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-text-muted text-sm mt-1 font-mono">
|
||||
<span className="flex items-center">
|
||||
<Folder className="w-4 h-4 mr-1" />
|
||||
{project.defaultDirectory}
|
||||
</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<div className="flex items-center text-text-muted text-sm mt-1">
|
||||
<span>{project.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ProjectHeader>
|
||||
|
||||
<ProjectSessions
|
||||
sessions={sessions}
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
onRetry={loadProjectData}
|
||||
onRemoveSession={handleRemoveSession}
|
||||
onAddSession={() => setIsAddSessionModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
<AddSessionToProjectModal
|
||||
isOpen={isAddSessionModalOpen}
|
||||
onClose={() => setIsAddSessionModalOpen(false)}
|
||||
project={project}
|
||||
availableSessions={getSessionsNotInProject()}
|
||||
onSessionsAdded={loadProjectData}
|
||||
/>
|
||||
|
||||
<UpdateProjectModal
|
||||
isOpen={isUpdateModalOpen}
|
||||
onClose={() => setIsUpdateModalOpen(false)}
|
||||
project={{
|
||||
...project,
|
||||
sessionCount: sessions.length,
|
||||
}}
|
||||
onRefresh={loadProjectData}
|
||||
/>
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete this project?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete the project "{project.name}". The sessions within this project won't
|
||||
be deleted, but they will no longer be part of this project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteProject();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetailsView;
|
||||
@@ -1,33 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import ProjectsView from './ProjectsView';
|
||||
import ProjectDetailsView from './ProjectDetailsView';
|
||||
import { View, ViewOptions } from '../../App';
|
||||
|
||||
interface ProjectsContainerProps {
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
}
|
||||
|
||||
const ProjectsContainer: React.FC<ProjectsContainerProps> = ({ setView }) => {
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
const handleSelectProject = (projectId: string) => {
|
||||
setSelectedProjectId(projectId);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedProjectId(null);
|
||||
// Trigger a refresh of the projects list when returning from details
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
if (selectedProjectId) {
|
||||
return (
|
||||
<ProjectDetailsView projectId={selectedProjectId} onBack={handleBack} setView={setView} />
|
||||
);
|
||||
}
|
||||
|
||||
return <ProjectsView onSelectProject={handleSelectProject} refreshTrigger={refreshTrigger} />;
|
||||
};
|
||||
|
||||
export default ProjectsContainer;
|
||||
@@ -1,214 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ProjectMetadata } from '../../projects';
|
||||
import { fetchProjects, createProject } from '../../projects';
|
||||
import ProjectCard from './ProjectCard';
|
||||
import CreateProjectModal from './CreateProjectModal';
|
||||
import { Button } from '../ui/button';
|
||||
import { FolderPlus, AlertCircle } from 'lucide-react';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
interface ProjectsViewProps {
|
||||
onSelectProject: (projectId: string) => void;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
const ProjectsView: React.FC<ProjectsViewProps> = ({ onSelectProject, refreshTrigger = 0 }) => {
|
||||
const [projects, setProjects] = useState<ProjectMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
|
||||
// Load projects on component mount and when refreshTrigger changes
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// Minimum loading time to prevent skeleton flash
|
||||
useEffect(() => {
|
||||
if (!loading && showSkeleton) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowSkeleton(false);
|
||||
// Add a small delay before showing content for fade-in effect
|
||||
setTimeout(() => {
|
||||
setShowContent(true);
|
||||
}, 50);
|
||||
}, 300); // Show skeleton for at least 300ms
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return () => void 0;
|
||||
}, [loading, showSkeleton]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowSkeleton(true);
|
||||
setShowContent(false);
|
||||
setError(null);
|
||||
|
||||
const projectsList = await fetchProjects();
|
||||
setProjects(projectsList);
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err);
|
||||
setError('Failed to load projects. Please try again.');
|
||||
toastError({ title: 'Error', msg: 'Failed to load projects' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get the current working directory or fallback to home
|
||||
const getDefaultDirectory = () => {
|
||||
if (window.appConfig && typeof window.appConfig.get === 'function') {
|
||||
const dir = window.appConfig.get('GOOSE_WORKING_DIR');
|
||||
return typeof dir === 'string' ? dir : '';
|
||||
}
|
||||
return typeof process !== 'undefined' && process.env && typeof process.env.HOME === 'string'
|
||||
? process.env.HOME
|
||||
: '';
|
||||
};
|
||||
|
||||
const handleCreateProject = async (
|
||||
name: string,
|
||||
description: string,
|
||||
defaultDirectory?: string
|
||||
) => {
|
||||
try {
|
||||
await createProject({
|
||||
name,
|
||||
description: description.trim() === '' ? undefined : description,
|
||||
defaultDirectory: defaultDirectory || getDefaultDirectory(),
|
||||
});
|
||||
|
||||
setIsCreateModalOpen(false);
|
||||
toastSuccess({ title: 'Success', msg: `Project "${name}" created successfully` });
|
||||
|
||||
// Refresh the projects list to get the updated data from the server
|
||||
await loadProjects();
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to create project' });
|
||||
}
|
||||
};
|
||||
|
||||
// Render skeleton loader for project items
|
||||
const ProjectSkeleton = () => (
|
||||
<div className="p-2 mb-2 bg-background-default border border-border-subtle rounded-lg">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Skeleton className="h-5 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading || showSkeleton) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<div className="space-y-2">
|
||||
<ProjectSkeleton />
|
||||
<ProjectSkeleton />
|
||||
<ProjectSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 Projects</p>
|
||||
<p className="text-sm text-center mb-4">{error}</p>
|
||||
<Button onClick={loadProjects} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center h-full">
|
||||
<p className="text-lg">No projects yet</p>
|
||||
<p className="text-sm mb-4 text-text-muted">
|
||||
Create your first project to organize related sessions together
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
onRefresh={loadProjects}
|
||||
/>
|
||||
))}
|
||||
</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">Projects</h1>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Create and manage your projects to organize related sessions together.
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Button onClick={() => setIsCreateModalOpen(true)} className="self-start">
|
||||
<FolderPlus className="h-4 w-4 mr-2" />
|
||||
New project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 relative px-8">
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={`h-full relative transition-all duration-300 ${
|
||||
showContent ? 'opacity-100 animate-in fade-in' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateProjectModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreateProject}
|
||||
defaultDirectory={getDefaultDirectory()}
|
||||
/>
|
||||
</MainPanelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsView;
|
||||
@@ -1,181 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { FolderSearch } from 'lucide-react';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { ProjectMetadata, updateProject, UpdateProjectRequest } from '../../projects';
|
||||
|
||||
interface UpdateProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
project: ProjectMetadata;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const UpdateProjectModal: React.FC<UpdateProjectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
project,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [defaultDirectory, setDefaultDirectory] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initialize form with project data
|
||||
useEffect(() => {
|
||||
if (isOpen && project) {
|
||||
setName(project.name);
|
||||
setDescription(project.description || '');
|
||||
setDefaultDirectory(project.defaultDirectory);
|
||||
}
|
||||
}, [isOpen, project]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
toastError({ title: 'Error', msg: 'Project name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defaultDirectory.trim()) {
|
||||
toastError({ title: 'Error', msg: 'Default directory is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Create update object, only include changed fields
|
||||
const updateData: UpdateProjectRequest = {};
|
||||
|
||||
if (name !== project.name) {
|
||||
updateData.name = name;
|
||||
}
|
||||
|
||||
if (description !== (project.description || '')) {
|
||||
updateData.description = description || null;
|
||||
}
|
||||
|
||||
if (defaultDirectory !== project.defaultDirectory) {
|
||||
updateData.defaultDirectory = defaultDirectory;
|
||||
}
|
||||
|
||||
// Only make the API call if there are changes
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await updateProject(project.id, updateData);
|
||||
toastSuccess({ title: 'Success', msg: 'Project updated successfully' });
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to update project:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to update project' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
try {
|
||||
// Use Electron's dialog to pick a directory
|
||||
const directory = await window.electron.directoryChooser();
|
||||
|
||||
if (!directory.canceled && directory.filePaths.length > 0) {
|
||||
setDefaultDirectory(directory.filePaths[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to pick directory:', err);
|
||||
toastError({ title: 'Error', msg: 'Failed to pick directory' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Project</DialogTitle>
|
||||
<DialogDescription>Update project information</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name*
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-start gap-2">
|
||||
<Label htmlFor="description" className="text-right pt-2">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3 resize-none"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-2">
|
||||
<Label htmlFor="directory" className="text-right">
|
||||
Directory*
|
||||
</Label>
|
||||
<div className="col-span-3 flex gap-2">
|
||||
<Input
|
||||
id="directory"
|
||||
value={defaultDirectory}
|
||||
onChange={(e) => setDefaultDirectory(e.target.value)}
|
||||
className="flex-grow"
|
||||
required
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handlePickDirectory}>
|
||||
<FolderSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateProjectModal;
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription } from '../ui/card';
|
||||
// import { Folder } from 'lucide-react';
|
||||
import { getApiUrl } from '../../config';
|
||||
import { Greeting } from '../common/Greeting';
|
||||
import { fetchSessions, fetchSessionDetails, type Session } from '../../sessions';
|
||||
// import { fetchProjects, type ProjectMetadata } from '../../projects';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../ui/button';
|
||||
import { ChatSmart } from '../icons/';
|
||||
@@ -24,7 +22,6 @@ export function SessionInsights() {
|
||||
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(true);
|
||||
// const [recentProjects, setRecentProjects] = useState<ProjectMetadata[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,15 +72,6 @@ export function SessionInsights() {
|
||||
}
|
||||
};
|
||||
|
||||
// const loadRecentProjects = async () => {
|
||||
// try {
|
||||
// const projects = await fetchProjects();
|
||||
// setRecentProjects(projects.slice(0, 3));
|
||||
// } catch (error) {
|
||||
// console.error('Failed to load recent projects:', error);
|
||||
// }
|
||||
// };
|
||||
|
||||
// Set a maximum loading time to prevent infinite skeleton
|
||||
loadingTimeout = setTimeout(() => {
|
||||
// Only apply fallback if we still don't have insights data
|
||||
@@ -107,7 +95,6 @@ export function SessionInsights() {
|
||||
|
||||
loadInsights();
|
||||
loadRecentSessions();
|
||||
// loadRecentProjects();
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
return () => {
|
||||
@@ -141,17 +128,6 @@ export function SessionInsights() {
|
||||
navigate('/sessions');
|
||||
};
|
||||
|
||||
// const navigateToProjects = () => {
|
||||
// navigate('/projects');
|
||||
// };
|
||||
//
|
||||
// const handleProjectClick = (projectId: string) => {
|
||||
// navigate('/projects', {
|
||||
// state: { selectedProjectId: projectId },
|
||||
// replace: true,
|
||||
// });
|
||||
// };
|
||||
|
||||
// Format date to show only the date part (without time)
|
||||
const formatDateOnly = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
@@ -335,60 +311,6 @@ export function SessionInsights() {
|
||||
|
||||
{/* Recent Chats Card */}
|
||||
<div className="grid grid-cols-1 gap-0.5">
|
||||
{/* Recent Projects Card */}
|
||||
{/*<Card className="w-full py-6 px-4 border-none rounded-tl-none rounded-bl-none">*/}
|
||||
{/* <CardContent className="animate-in fade-in duration-500 px-4">*/}
|
||||
{/* <div className="flex justify-between items-center mb-2 px-2">*/}
|
||||
{/* <CardDescription className="mb-0">*/}
|
||||
{/* <span className="text-lg text-text-default">Recent projects</span>*/}
|
||||
{/* </CardDescription>*/}
|
||||
{/* <Button*/}
|
||||
{/* variant="ghost"*/}
|
||||
{/* size="sm"*/}
|
||||
{/* className="text-xs text-text-muted flex items-center gap-1 !px-0 hover:bg-transparent hover:underline hover:text-text-default"*/}
|
||||
{/* onClick={navigateToProjects}*/}
|
||||
{/* >*/}
|
||||
{/* See all*/}
|
||||
{/* </Button>*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="space-y-1 min-h-[96px] transition-all duration-300 ease-in-out">*/}
|
||||
{/* <AnimatePresence>*/}
|
||||
{/* {recentProjects.length > 0 ? (*/}
|
||||
{/* recentProjects.map((project, index) => (*/}
|
||||
{/* <motion.div*/}
|
||||
{/* key={project.id}*/}
|
||||
{/* className="flex items-center justify-between text-sm py-1 px-2 rounded-md hover:bg-background-muted cursor-pointer transition-colors"*/}
|
||||
{/* onClick={() => handleProjectClick(project.id)}*/}
|
||||
{/* role="button"*/}
|
||||
{/* tabIndex={0}*/}
|
||||
{/* initial={{ opacity: 0, y: 5 }}*/}
|
||||
{/* animate={{ opacity: 1, y: 0 }}*/}
|
||||
{/* transition={{ duration: 0.3, delay: index * 0.1 }}*/}
|
||||
{/* onKeyDown={(e) => {*/}
|
||||
{/* if (e.key === 'Enter' || e.key === ' ') {*/}
|
||||
{/* handleProjectClick(project.id);*/}
|
||||
{/* }*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* <div className="flex items-center space-x-2">*/}
|
||||
{/* <Folder className="h-4 w-4 text-text-muted" />*/}
|
||||
{/* <span className="truncate max-w-[200px]">{project.name}</span>*/}
|
||||
{/* </div>*/}
|
||||
{/* <span className="text-text-muted font-mono font-light">*/}
|
||||
{/* {formatDateOnly(project.updatedAt)}*/}
|
||||
{/* </span>*/}
|
||||
{/* </motion.div>*/}
|
||||
{/* ))*/}
|
||||
{/* ) : (*/}
|
||||
{/* <div className="text-text-muted text-sm py-2 px-2">*/}
|
||||
{/* No recent projects found.*/}
|
||||
{/* </div>*/}
|
||||
{/* )}*/}
|
||||
{/* </AnimatePresence>*/}
|
||||
{/* </div>*/}
|
||||
{/* </CardContent>*/}
|
||||
{/*</Card>*/}
|
||||
|
||||
{/* Recent Chats Card */}
|
||||
<Card className="w-full py-6 px-6 border-none rounded-2xl bg-background-default">
|
||||
<CardContent className="page-transition p-0">
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import Model from '../modelInterface';
|
||||
import { useRecentModels } from './recentModels';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import { toastInfo } from '../../../../toasts';
|
||||
|
||||
interface ModelRadioListProps {
|
||||
renderItem: (props: {
|
||||
model: Model;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => React.ReactNode;
|
||||
className?: string;
|
||||
providedModelList?: Model[];
|
||||
}
|
||||
|
||||
// renders a model list and handles changing models when user clicks on them
|
||||
export function BaseModelsList({
|
||||
renderItem,
|
||||
className = '',
|
||||
providedModelList,
|
||||
}: ModelRadioListProps) {
|
||||
const { recentModels } = useRecentModels();
|
||||
|
||||
// allow for a custom model list to be passed if you don't want to use recent models
|
||||
let modelList: Model[];
|
||||
if (!providedModelList) {
|
||||
modelList = recentModels;
|
||||
} else {
|
||||
modelList = providedModelList;
|
||||
}
|
||||
const { changeModel, getCurrentModelAndProvider, currentModel, currentProvider } =
|
||||
useModelAndProvider();
|
||||
const [selectedModel, setSelectedModel] = useState<Model | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Load current model/provider once on component mount
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const initializeCurrentModel = async () => {
|
||||
try {
|
||||
const result = await getCurrentModelAndProvider();
|
||||
if (isMounted) {
|
||||
// try to look up the model in the modelList
|
||||
let currentModel: Model;
|
||||
const match = modelList.find(
|
||||
(model) => model.name == result.model && model.provider == result.provider
|
||||
);
|
||||
// no matches so just create a model object (maybe user updated config.yaml from CLI usage, manual editing etc)
|
||||
if (!match) {
|
||||
currentModel = { name: String(result.model), provider: String(result.provider) };
|
||||
} else {
|
||||
currentModel = match;
|
||||
}
|
||||
setSelectedModel(currentModel);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load current model:', error);
|
||||
if (isMounted) {
|
||||
setIsInitialized(true); // Still mark as initialized even on error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeCurrentModel().then();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [getCurrentModelAndProvider, modelList]);
|
||||
|
||||
const handleModelSelection = async (model: Model) => {
|
||||
await changeModel(model);
|
||||
};
|
||||
|
||||
const handleRadioChange = async (model: Model) => {
|
||||
// Check if the selected model is already active
|
||||
if (
|
||||
selectedModel &&
|
||||
selectedModel.name === model.name &&
|
||||
selectedModel.provider === model.provider
|
||||
) {
|
||||
toastInfo({
|
||||
title: 'No change',
|
||||
msg: `Model "${model.name}" is already active.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// OPTIMISTIC UPDATE: Update the UI immediately
|
||||
setSelectedModel(model);
|
||||
|
||||
try {
|
||||
// Then perform the actual model change
|
||||
await handleModelSelection(model);
|
||||
} catch (error) {
|
||||
console.error('Error selecting model:', error);
|
||||
|
||||
// If the operation fails, revert to the previous state by simply
|
||||
// re-calling the getCurrentModelAndProvider function
|
||||
try {
|
||||
const result = await getCurrentModelAndProvider();
|
||||
|
||||
const currentModel =
|
||||
modelList.find((m) => m.name === result.model && m.provider === result.provider) ||
|
||||
({ name: String(result.model), provider: String(result.provider) } as Model);
|
||||
|
||||
setSelectedModel(currentModel);
|
||||
} catch (secondError) {
|
||||
console.error('Failed to restore previous model:', secondError);
|
||||
}
|
||||
|
||||
// Show an error toast
|
||||
toastInfo({
|
||||
title: 'Error',
|
||||
msg: `Failed to switch to model "${model.name}".`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update selected model when context changes - but only if they actually changed
|
||||
const prevModelRef = useRef<string | null>(null);
|
||||
const prevProviderRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentModel &&
|
||||
currentProvider &&
|
||||
isInitialized &&
|
||||
(currentModel !== prevModelRef.current || currentProvider !== prevProviderRef.current)
|
||||
) {
|
||||
prevModelRef.current = currentModel;
|
||||
prevProviderRef.current = currentProvider;
|
||||
|
||||
const match = modelList.find(
|
||||
(model) => model.name === currentModel && model.provider === currentProvider
|
||||
);
|
||||
|
||||
if (match) {
|
||||
setSelectedModel(match);
|
||||
} else {
|
||||
// Create a model object if not found in list
|
||||
setSelectedModel({ name: currentModel, provider: currentProvider });
|
||||
}
|
||||
}
|
||||
}, [currentModel, currentProvider, modelList, isInitialized]);
|
||||
|
||||
// Don't render until we've loaded the initial model/provider
|
||||
if (!isInitialized) {
|
||||
return <div>Loading models...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{modelList.map((model) =>
|
||||
renderItem({
|
||||
model,
|
||||
isSelected: !!(
|
||||
selectedModel &&
|
||||
selectedModel.name === model.name &&
|
||||
selectedModel.provider === model.provider
|
||||
),
|
||||
onSelect: () => handleRadioChange(model),
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Model from '../modelInterface';
|
||||
|
||||
const MAX_RECENT_MODELS = 3;
|
||||
|
||||
export function useRecentModels() {
|
||||
const [recentModels, setRecentModels] = useState<Model[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedModels = localStorage.getItem('recentModels');
|
||||
if (storedModels) {
|
||||
setRecentModels(JSON.parse(storedModels));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addRecentModel = (model: Model) => {
|
||||
const modelWithTimestamp = { ...model, lastUsed: new Date().toISOString() }; // Add lastUsed field
|
||||
setRecentModels((prevModels) => {
|
||||
const updatedModels = [
|
||||
modelWithTimestamp,
|
||||
...prevModels.filter((m) => m.name !== model.name),
|
||||
].slice(0, MAX_RECENT_MODELS);
|
||||
|
||||
localStorage.setItem('recentModels', JSON.stringify(updatedModels));
|
||||
return updatedModels;
|
||||
});
|
||||
};
|
||||
|
||||
return { recentModels, addRecentModel };
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import ProviderState from '../interfaces/ProviderState';
|
||||
|
||||
export default interface ButtonCallbacks {
|
||||
onConfigure?: (provider: ProviderState) => void;
|
||||
onLaunch?: (provider: ProviderState) => void;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// contains basic, common actions like edit, add, delete etc
|
||||
// logic for whether or not these buttons get shown is stored in the actions/ folder
|
||||
// specific providers may want specific methods to handle these operations -- TODO
|
||||
export default interface ConfigurationAction {
|
||||
id: string;
|
||||
renderButton: () => React.JSX.Element;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default interface OllamaMetadata {
|
||||
location: 'app' | 'host' | null;
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { QUICKSTART_GUIDE_URL } from '../constants';
|
||||
|
||||
interface ProviderSetupHeaderProps {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the header (title + description + link to guide) for the modal.
|
||||
*/
|
||||
export default function ProviderSetupHeader({ title, body }: ProviderSetupHeaderProps) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-medium text-textStandard mb-3">{title}</h2>
|
||||
<div className="text-lg text-gray-400 font-light mb-4">{body}</div>
|
||||
<a
|
||||
href={QUICKSTART_GUIDE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center text-textProminent text-sm"
|
||||
>
|
||||
<ExternalLink size={16} className="mr-1" />
|
||||
View quick start guide
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import ConfigurationAction from '../interfaces/ConfigurationAction';
|
||||
|
||||
interface CardActionsProps {
|
||||
actions: ConfigurationAction[];
|
||||
}
|
||||
|
||||
export default function CardActions({ actions }: CardActionsProps) {
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
{actions.map((action) => {
|
||||
// Store the rendered button in a variable first
|
||||
const ButtonElement = action.renderButton();
|
||||
return <React.Fragment key={action.id}>{ButtonElement}</React.Fragment>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function ViewRecipe() {
|
||||
return <div>ViewRecipe</div>;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export default function Box({ size }: { size: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ height: size, width: size }}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1 4.35L7 1.25L13 4.35M1 4.35L7 7.25M1 4.35V10.75L7 13.75M13 4.35L7 7.25M13 4.35V10.75L7 13.75M7 7.25V13.75"
|
||||
stroke="url(#paint0_linear_113_4015)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_113_4015"
|
||||
x1="-5"
|
||||
y1="-7.25"
|
||||
x2="27.1928"
|
||||
y2="20.7684"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.230048" stopColor="#2E7CF6" />
|
||||
<stop offset="0.430048" stopColor="#F200FF" stopOpacity="0.25" />
|
||||
<stop offset="0.615048" stopColor="#FAC145" stopOpacity="0.669964" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Copy from '../icons/Copy';
|
||||
import { Card } from './card';
|
||||
import { Recipe, generateDeepLink } from '../../recipe';
|
||||
|
||||
interface DeepLinkModalProps {
|
||||
recipe: Recipe;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DeepLinkModal({ recipe: initialRecipe, onClose }: DeepLinkModalProps) {
|
||||
// Create editable state for the recipe
|
||||
const [recipe, setRecipe] = useState(initialRecipe);
|
||||
const [instructions, setInstructions] = useState(initialRecipe.instructions || '');
|
||||
const [activities, setActivities] = useState<string[]>(initialRecipe.activities || []);
|
||||
const [activityInput, setActivityInput] = useState('');
|
||||
|
||||
// State for the deep link
|
||||
const [deepLink, setDeepLink] = useState('');
|
||||
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
|
||||
|
||||
// Generate the deep link using the current bot config
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const generateLink = async () => {
|
||||
setIsGeneratingLink(true);
|
||||
try {
|
||||
const currentConfig = {
|
||||
...recipe,
|
||||
instructions,
|
||||
activities,
|
||||
title: recipe.title || 'Generated Recipe',
|
||||
description: recipe.description || 'Recipe created from chat',
|
||||
};
|
||||
const link = await generateDeepLink(currentConfig);
|
||||
if (!isCancelled) {
|
||||
setDeepLink(link);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate deeplink:', error);
|
||||
if (!isCancelled) {
|
||||
setDeepLink('Error generating deeplink');
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsGeneratingLink(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
generateLink();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [recipe, instructions, activities]);
|
||||
|
||||
// Handle Esc key press
|
||||
useEffect(() => {
|
||||
const handleEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Update the recipe when instructions or activities change
|
||||
useEffect(() => {
|
||||
setRecipe({
|
||||
...recipe,
|
||||
instructions,
|
||||
activities,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instructions, activities]);
|
||||
|
||||
// Handle adding a new activity
|
||||
const handleAddActivity = () => {
|
||||
if (activityInput.trim()) {
|
||||
setActivities([...activities, activityInput.trim()]);
|
||||
setActivityInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle removing an activity
|
||||
const handleRemoveActivity = (index: number) => {
|
||||
const newActivities = [...activities];
|
||||
newActivities.splice(index, 1);
|
||||
setActivities(newActivities);
|
||||
};
|
||||
|
||||
// Reference for the modal content
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle click outside the modal
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors flex items-center justify-center p-4 z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<Card
|
||||
ref={modalRef}
|
||||
className="relative w-[700px] max-w-full bg-background-default rounded-xl my-10 max-h-[90vh] flex flex-col shadow-lg"
|
||||
>
|
||||
<div className="p-8 overflow-y-auto" style={{ maxHeight: 'calc(90vh - 32px)' }}>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-2xl font-bold mb-4 text-textStandard">Agent Created!</h2>
|
||||
<p className="mb-4 text-textStandard">
|
||||
Your agent has been created successfully. You can share or open it below:
|
||||
</p>
|
||||
|
||||
{/* Sharable Goose Bot Section - Moved to top */}
|
||||
<div className="mb-6">
|
||||
<label className="block font-medium mb-1 text-textStandard">
|
||||
Sharable Goose Bot:
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={isGeneratingLink ? 'Generating deeplink...' : deepLink}
|
||||
readOnly
|
||||
className="flex-1 p-3 border border-borderSubtle rounded-l-md bg-transparent text-textStandard"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isGeneratingLink && deepLink && deepLink !== 'Error generating deeplink') {
|
||||
navigator.clipboard.writeText(deepLink);
|
||||
window.electron.logInfo('Deep link copied to clipboard');
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isGeneratingLink || !deepLink || deepLink === 'Error generating deeplink'
|
||||
}
|
||||
className="p-2 bg-blue-500 text-white rounded-r-md hover:bg-blue-600 flex items-center justify-center min-w-[100px] disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Copy className="w-5 h-5 mr-1" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Moved to top */}
|
||||
<div className="flex mb-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Open the deep link with the current recipe config
|
||||
const currentConfig = {
|
||||
...recipe,
|
||||
instructions,
|
||||
activities,
|
||||
title: recipe.title || 'DeepLink Recipe',
|
||||
description: recipe.description || 'Recipe from deep link',
|
||||
};
|
||||
window.electron.createChatWindow(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
currentConfig
|
||||
);
|
||||
// Don't close the modal
|
||||
}}
|
||||
className="px-5 py-2.5 bg-green-500 text-white rounded-md hover:bg-green-600 flex-1 mr-2"
|
||||
>
|
||||
Open Agent
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 bg-gray-500 text-white rounded-md hover:bg-gray-600 flex-1"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-medium mb-3 text-textStandard">Edit Instructions:</h3>
|
||||
<div className="mb-4">
|
||||
<div className="border border-borderSubtle rounded-md bg-transparent max-h-[120px] overflow-y-auto">
|
||||
<textarea
|
||||
id="instructions"
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
className="w-full p-3 bg-transparent text-textStandard focus:outline-none"
|
||||
placeholder="Instructions for the agent..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activities Section */}
|
||||
<div className="mb-4">
|
||||
<label className="block font-medium mb-1 text-textStandard">Activities:</label>
|
||||
<div className="border border-borderSubtle rounded-md bg-transparent max-h-[120px] overflow-y-auto mb-2">
|
||||
<ul className="divide-y divide-borderSubtle">
|
||||
{activities.map((activity, index) => (
|
||||
<li key={index} className="flex items-center">
|
||||
<span className="flex-1 p-2 text-textStandard">{activity}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveActivity(index)}
|
||||
className="p-1 bg-red-500 text-white rounded-md hover:bg-red-600 m-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<input
|
||||
type="text"
|
||||
value={activityInput}
|
||||
onChange={(e) => setActivityInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
|
||||
className="flex-1 p-2 border border-borderSubtle rounded-l-md bg-transparent text-textStandard focus:border-borderStandard hover:border-borderStandard"
|
||||
placeholder="Add new activity..."
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddActivity}
|
||||
className="p-2 bg-green-500 text-white rounded-r-md hover:bg-green-600"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export default function Send({ size }: { size: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ height: size, width: size }}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M22 12.5L2 4.5L4 12.5L2 20.5L22 12.5ZM5.81 13.5H14.11L4.88 17.19L5.81 13.5ZM14.11 11.5H5.81L4.89 7.81L14.11 11.5Z"
|
||||
fill="#7A7EFB"
|
||||
className="dark:fill-[#4A56E2]"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export default function VertDots({ size }: { size: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
fill="none"
|
||||
className="text-gray-600 dark:text-prev-goose-text-dark"
|
||||
>
|
||||
<path
|
||||
d="M10.4976 4.5C10.4976 5.32843 9.82599 6 8.99756 6C8.16913 6 7.49756 5.32843 7.49756 4.5C7.49756 3.67157 8.16913 3 8.99756 3C9.82599 3 10.4976 3.67157 10.4976 4.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M10.4976 9C10.4976 9.82843 9.82599 10.5 8.99756 10.5C8.16913 10.5 7.49756 9.82843 7.49756 9C7.49756 8.17157 8.16913 7.5 8.99756 7.5C9.82599 7.5 10.4976 8.17157 10.4976 9Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8.99756 15C9.82599 15 10.4976 14.3284 10.4976 13.5C10.4976 12.6716 9.82599 12 8.99756 12C8.16913 12 7.49756 12.6716 7.49756 13.5C7.49756 14.3284 8.16913 15 8.99756 15Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export default function X({ size }: { size: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.97237 6.00001L3.82593 3.85356L4.53303 3.14645L6.67948 5.2929L8.82593 3.14645L9.53303 3.85356L7.38659 6.00001L9.53303 8.14645L8.82593 8.85356L6.67948 6.70711L4.53303 8.85356L3.82593 8.14645L5.97237 6.00001Z"
|
||||
fill="black"
|
||||
className="dark:fill-white"
|
||||
fillOpacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
'bg-background-default data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn('text-lg font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn('text-text-muted text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted dark:bg-muted-dark',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -1,33 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-border-input data-[state=checked]:bg-background-accent data-[state=checked]:text-text-on-accent data-[state=checked]:border-border-inverse focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[1px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex gap-2 text-sm leading-none select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -1,98 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Close } from '../icons';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const Modal = DialogPrimitive.Root;
|
||||
|
||||
const ModalTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const ModalPortal = DialogPrimitive.Portal;
|
||||
|
||||
const ModalClose = DialogPrimitive.Close;
|
||||
|
||||
const ModalOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ModalOverlay.displayName = 'ModalOverlay';
|
||||
|
||||
const ModalContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ModalPortal>
|
||||
<ModalOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-800 p-6 shadow-lg sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Close className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</ModalPortal>
|
||||
));
|
||||
ModalContent.displayName = 'ModalContent';
|
||||
|
||||
const ModalHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
ModalHeader.displayName = 'ModalHeader';
|
||||
|
||||
const ModalFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
ModalFooter.displayName = 'ModalFooter';
|
||||
|
||||
const ModalTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ModalTitle.displayName = 'ModalTitle';
|
||||
|
||||
const ModalDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ModalDescription.displayName = 'ModalDescription';
|
||||
|
||||
export {
|
||||
Modal,
|
||||
ModalPortal,
|
||||
ModalOverlay,
|
||||
ModalTrigger,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverPortal = PopoverPrimitive.Portal;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-app text-popover-foreground outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
transform: 'none',
|
||||
top: 'auto',
|
||||
left: 'auto',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverPortal };
|
||||
@@ -1,84 +0,0 @@
|
||||
import { StylesConfig, ThemeConfig } from 'react-select';
|
||||
|
||||
export const createDarkSelectStyles = (minWidth?: string): StylesConfig => ({
|
||||
control: (base) => ({
|
||||
...base,
|
||||
...(minWidth ? { minWidth } : {}),
|
||||
backgroundColor: '#1a1b1e',
|
||||
borderColor: '#2a2b2e',
|
||||
color: '#ffffff',
|
||||
}),
|
||||
menu: (base) => ({
|
||||
...base,
|
||||
backgroundColor: '#1a1b1e',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
border: '1px solid #2a2b2e',
|
||||
background: '#1a1b1e',
|
||||
}),
|
||||
menuList: (base) => ({
|
||||
...base,
|
||||
backgroundColor: '#1a1b1e',
|
||||
background: '#1a1b1e',
|
||||
padding: '4px',
|
||||
}),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
backgroundColor: state.isFocused ? '#2a2b2e' : '#1a1b1e',
|
||||
color: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
background: state.isFocused ? '#2a2b2e' : '#1a1b1e',
|
||||
':hover': {
|
||||
backgroundColor: '#2a2b2e',
|
||||
color: '#ffffff',
|
||||
background: '#2a2b2e',
|
||||
},
|
||||
padding: '8px',
|
||||
margin: '2px 0',
|
||||
borderRadius: '4px',
|
||||
}),
|
||||
singleValue: (base) => ({
|
||||
...base,
|
||||
color: '#ffffff',
|
||||
}),
|
||||
input: (base) => ({
|
||||
...base,
|
||||
color: '#ffffff',
|
||||
}),
|
||||
placeholder: (base) => ({
|
||||
...base,
|
||||
color: '#9ca3af',
|
||||
}),
|
||||
dropdownIndicator: (base) => ({
|
||||
...base,
|
||||
color: '#9ca3af',
|
||||
':hover': {
|
||||
color: '#ffffff',
|
||||
},
|
||||
}),
|
||||
indicatorSeparator: (base) => ({
|
||||
...base,
|
||||
backgroundColor: '#2a2b2e',
|
||||
}),
|
||||
});
|
||||
|
||||
export const darkSelectTheme: ThemeConfig = (theme) => ({
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary: '#2a2b2e',
|
||||
primary75: '#2a2b2e',
|
||||
primary50: '#2a2b2e',
|
||||
primary25: '#2a2b2e',
|
||||
neutral0: '#1a1b1e',
|
||||
neutral5: '#1a1b1e',
|
||||
neutral10: '#2a2b2e',
|
||||
neutral20: '#2a2b2e',
|
||||
neutral30: '#3a3b3e',
|
||||
neutral40: '#ffffff',
|
||||
neutral50: '#ffffff',
|
||||
neutral60: '#ffffff',
|
||||
neutral70: '#ffffff',
|
||||
neutral80: '#ffffff',
|
||||
neutral90: '#ffffff',
|
||||
},
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-border-input placeholder:text-text-muted focus-visible:border-ring focus-visible:ring-ring/50 shadow-xs aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[1px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,68 +0,0 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../utils';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
const Tabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Root
|
||||
orientation="vertical"
|
||||
ref={ref}
|
||||
className={cn('flex rounded-md text-text-muted gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Tabs.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col h-auto justify-start rounded-md bg-background-default p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center justify-start whitespace-nowrap rounded-lg px-3 py-1.5 text-sm ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background-muted data-[state=active]:text-text-default data-[state=active]:shadow-sm hover:bg-background-muted hover:text-text-default',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ml-4 mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 animate-in fade-in slide-in-from-right-8 duration-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
@@ -1,2 +0,0 @@
|
||||
export const GOOSE_PROVIDER = 'GOOSE_PROVIDER';
|
||||
export const GOOSE_MODEL = 'GOOSE_MODEL';
|
||||
@@ -1,28 +0,0 @@
|
||||
// Handle window movement and docking detection
|
||||
let isDragging = false;
|
||||
let startX, startY;
|
||||
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
startX = e.screenX - window.screenX;
|
||||
startY = e.screenY - window.screenY;
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
// Check for docking with parent window
|
||||
if (window.electronFloating) {
|
||||
window.electronFloating.checkDocking();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle click to focus parent window
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!isDragging && window.electronFloating) {
|
||||
window.electronFloating.focusParent();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Floating button script loaded');
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDarkMode(): boolean {
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
|
||||
const html = document.documentElement;
|
||||
return (
|
||||
html.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
|
||||
const updateDarkMode = () => {
|
||||
setIsDarkMode(html.classList.contains('dark'));
|
||||
};
|
||||
|
||||
// Observe class attribute changes
|
||||
const observer = new MutationObserver(() => updateDarkMode());
|
||||
observer.observe(html, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Also handle system preference changes (if no dark class is set manually)
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
// eslint-disable-next-line no-undef
|
||||
const handleMediaChange = (event: MediaQueryListEvent) => {
|
||||
if (!html.classList.contains('dark') && !html.classList.contains('light')) {
|
||||
setIsDarkMode(event.matches);
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
|
||||
// Initial check
|
||||
updateDarkMode();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isDarkMode;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { Session } from './sessions';
|
||||
import { client } from './api/client.gen';
|
||||
|
||||
/**
|
||||
* Interface for a project with all details
|
||||
*/
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
defaultDirectory: string;
|
||||
sessionIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified project metadata for listings
|
||||
*/
|
||||
export interface ProjectMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
defaultDirectory: string;
|
||||
sessionCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project with associated session objects
|
||||
*/
|
||||
export interface ProjectWithSessions extends Project {
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create a new project
|
||||
*/
|
||||
export interface CreateProjectRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
defaultDirectory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to update an existing project
|
||||
*/
|
||||
export interface UpdateProjectRequest {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
defaultDirectory?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure default directory is properly set
|
||||
*/
|
||||
function ensureDefaultDirectory(project: Partial<Project>): Project {
|
||||
return {
|
||||
id: project.id || '',
|
||||
name: project.name || '',
|
||||
description: project.description || null,
|
||||
defaultDirectory: project.defaultDirectory || process.env.HOME || '',
|
||||
sessionIds: project.sessionIds || [],
|
||||
createdAt: project.createdAt || new Date().toISOString(),
|
||||
updatedAt: project.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all available projects from the API
|
||||
* @returns Promise with an array of ProjectMetadata objects
|
||||
*/
|
||||
export async function fetchProjects(): Promise<ProjectMetadata[]> {
|
||||
try {
|
||||
const response = await client.get<{ projects: ProjectMetadata[] }>({
|
||||
url: '/projects',
|
||||
});
|
||||
|
||||
if (response && response.data && response.data.projects) {
|
||||
return response.data.projects;
|
||||
} else {
|
||||
throw new Error('Unexpected response format from list_projects');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new project
|
||||
* @param request The project creation request data
|
||||
* @returns Promise with the created project
|
||||
*/
|
||||
export async function createProject(request: CreateProjectRequest): Promise<Project> {
|
||||
const response = await client.post<{ project: Project }>({
|
||||
url: '/projects',
|
||||
body: request,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
console.log('Raw createProject response:', response);
|
||||
return ensureDefaultDirectory(
|
||||
(response as { project?: Project }).project ?? (response as unknown as Project)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets details for a specific project
|
||||
* @param projectId The ID of the project to fetch
|
||||
* @returns Promise with project details
|
||||
*/
|
||||
export async function getProject(projectId: string): Promise<Project> {
|
||||
try {
|
||||
const response = await client.get<{ project: Project }>({
|
||||
url: `/projects/${projectId}`,
|
||||
});
|
||||
|
||||
if (!response?.data?.project) {
|
||||
throw new Error(`Unexpected response format from get_project_details for ID: ${projectId}`);
|
||||
}
|
||||
|
||||
return ensureDefaultDirectory(response.data.project);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing project
|
||||
* @param projectId The ID of the project to update
|
||||
* @param request The project update request data
|
||||
* @returns Promise with the updated project
|
||||
*/
|
||||
export async function updateProject(
|
||||
projectId: string,
|
||||
request: UpdateProjectRequest
|
||||
): Promise<Project> {
|
||||
try {
|
||||
const response = await client.put<{ project: Project }>({
|
||||
url: `/projects/${projectId}`,
|
||||
body: request,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response?.data?.project) {
|
||||
throw new Error(`Unexpected response format from update_project for ID: ${projectId}`);
|
||||
}
|
||||
|
||||
return ensureDefaultDirectory(response.data.project);
|
||||
} catch (error) {
|
||||
console.error(`Error updating project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a project
|
||||
* @param projectId The ID of the project to delete
|
||||
*/
|
||||
export async function deleteProject(projectId: string): Promise<void> {
|
||||
try {
|
||||
await client.delete({
|
||||
url: `/projects/${projectId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error deleting project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a session to a project
|
||||
* @param projectId The ID of the project
|
||||
* @param sessionId The ID of the session to add
|
||||
*/
|
||||
export async function addSessionToProject(projectId: string, sessionId: string): Promise<void> {
|
||||
try {
|
||||
await client.post({
|
||||
url: `/projects/${projectId}/sessions/${sessionId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error adding session ${sessionId} to project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a session from a project
|
||||
* @param projectId The ID of the project
|
||||
* @param sessionId The ID of the session to remove
|
||||
*/
|
||||
export async function removeSessionFromProject(
|
||||
projectId: string,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await client.delete({
|
||||
url: `/projects/${projectId}/sessions/${sessionId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error removing session ${sessionId} from project ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a project ID in the format proj_yyyymmdd_hhmmss
|
||||
*/
|
||||
export function generateProjectId(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `proj_${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { spawn as spawnProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function handleSquirrelEvent(): boolean {
|
||||
if (process.argv.length === 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appFolder = path.resolve(process.execPath, '..');
|
||||
const rootAtomFolder = path.resolve(appFolder, '..');
|
||||
const updateDotExe = path.resolve(path.join(rootAtomFolder, 'Update.exe'));
|
||||
const exeName = path.basename(process.execPath);
|
||||
|
||||
const spawnUpdate = function (args: string[]) {
|
||||
try {
|
||||
return spawnProcess(updateDotExe, args, { detached: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to spawn update process:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const squirrelEvent = process.argv[1];
|
||||
switch (squirrelEvent) {
|
||||
case '--squirrel-install':
|
||||
case '--squirrel-updated': {
|
||||
// Register protocol handler
|
||||
spawnUpdate(['--createShortcut', exeName]);
|
||||
|
||||
// Register protocol
|
||||
const regCommand = `Windows Registry Editor Version 5.00
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose]
|
||||
@="URL:Goose Protocol"
|
||||
"URL Protocol"=""
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose\\DefaultIcon]
|
||||
@="\\"${process.execPath.replace(/\\/g, '\\\\')},1\\""
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose\\shell]
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose\\shell\\open]
|
||||
|
||||
[HKEY_CLASSES_ROOT\\goose\\shell\\open\\command]
|
||||
@="\\"${process.execPath.replace(/\\/g, '\\\\')}\\" \\"%1\\""`;
|
||||
|
||||
fs.writeFileSync('goose-protocol.reg', regCommand);
|
||||
spawnProcess('regedit.exe', ['/s', 'goose-protocol.reg']);
|
||||
|
||||
setTimeout(() => app.quit(), 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
case '--squirrel-uninstall': {
|
||||
// Remove protocol handler
|
||||
spawnUpdate(['--removeShortcut', exeName]);
|
||||
setTimeout(() => app.quit(), 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
case '--squirrel-obsolete': {
|
||||
app.quit();
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
declare namespace ElectronTypes {
|
||||
interface Event {
|
||||
preventDefault: () => void;
|
||||
sender: unknown;
|
||||
}
|
||||
|
||||
interface IpcRendererEvent extends Event {
|
||||
senderId: number;
|
||||
}
|
||||
|
||||
interface MouseUpEvent extends Event {
|
||||
button: number;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './electron';
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Session } from '../sessions';
|
||||
|
||||
/**
|
||||
* Represents a project in the system
|
||||
*/
|
||||
export interface Project {
|
||||
/** Unique identifier for the project */
|
||||
id: string;
|
||||
/** Display name of the project */
|
||||
name: string;
|
||||
/** Optional description of the project */
|
||||
description?: string;
|
||||
/** Default working directory for sessions in this project */
|
||||
defaultDirectory: string;
|
||||
/** When the project was created */
|
||||
createdAt: string;
|
||||
/** When the project was last updated */
|
||||
updatedAt: string;
|
||||
/** List of session IDs associated with this project */
|
||||
sessionIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified project metadata for listings
|
||||
*/
|
||||
export interface ProjectMetadata {
|
||||
/** Unique identifier for the project */
|
||||
id: string;
|
||||
/** Display name of the project */
|
||||
name: string;
|
||||
/** Optional description of the project */
|
||||
description?: string;
|
||||
/** Default working directory for sessions in this project */
|
||||
defaultDirectory: string;
|
||||
/** Number of sessions in this project */
|
||||
sessionCount: number;
|
||||
/** When the project was created */
|
||||
createdAt: string;
|
||||
/** When the project was last updated */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project with associated sessions
|
||||
*/
|
||||
export interface ProjectWithSessions extends Project {
|
||||
/** Associated sessions */
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create a new project
|
||||
*/
|
||||
export interface CreateProjectRequest {
|
||||
/** Display name of the project */
|
||||
name: string;
|
||||
/** Optional description of the project */
|
||||
description?: string;
|
||||
/** Default working directory for sessions in this project */
|
||||
defaultDirectory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to update an existing project
|
||||
*/
|
||||
export interface UpdateProjectRequest {
|
||||
/** Display name of the project */
|
||||
name?: string;
|
||||
/** Optional description of the project */
|
||||
description?: string | null;
|
||||
/** Default working directory for sessions in this project */
|
||||
defaultDirectory?: string;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Test script for the extension confirmation dialog
|
||||
// This simulates clicking the "Add Extension" menu item
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
// Create a fake extension URL
|
||||
const extensionUrl = 'goose://extension?cmd=npx&arg=-y&arg=tavily-mcp&id=tavily&name=Tavily%20Web%20Search&description=Web%20search%20capabilities%20powered%20by%20Tavily&env=TAVILY_API_KEY%3DAPI%20key%20for%20Tavily%20web%20search%20service';
|
||||
|
||||
// Send the add-extension event
|
||||
ipcRenderer.send('add-extension', extensionUrl);
|
||||
Reference in New Issue
Block a user