UI update with sidebar and settings tabs (#3288)

Co-authored-by: Nahiyan Khan <nahiyan@squareup.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Lily Delalande <119957291+lily-de@users.noreply.github.com>
Co-authored-by: Spence <spencrmartin@gmail.com>
Co-authored-by: spencrmartin <spencermartin@squareup.com>
Co-authored-by: Judson Stephenson <Jud@users.noreply.github.com>
Co-authored-by: Max Novich <mnovich@squareup.com>
Co-authored-by: Best Codes <106822363+The-Best-Codes@users.noreply.github.com>
Co-authored-by: caroline-a-mckenzie <cmckenzie@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Zane
2025-07-15 17:24:41 -07:00
committed by GitHub
parent b22f50d1a1
commit 77ea27f5f5
195 changed files with 20633 additions and 7238 deletions

View File

@@ -82,7 +82,8 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with: with:
ref: ${{ inputs.ref }} # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
fetch-depth: 0 fetch-depth: 0
# Update versions before build # Update versions before build

View File

@@ -28,7 +28,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
with: with:
ref: ${{ inputs.ref }} # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
fetch-depth: 0 fetch-depth: 0
# 2) Update versions before build # 2) Update versions before build

View File

@@ -45,7 +45,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
with: with:
ref: ${{ inputs.ref }} # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
fetch-depth: 0 fetch-depth: 0
# 2) Configure AWS credentials for code signing # 2) Configure AWS credentials for code signing

View File

@@ -5,6 +5,7 @@ pub mod config_management;
pub mod context; pub mod context;
pub mod extension; pub mod extension;
pub mod health; pub mod health;
pub mod project;
pub mod recipe; pub mod recipe;
pub mod reply; pub mod reply;
pub mod schedule; pub mod schedule;
@@ -27,4 +28,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
.merge(recipe::routes(state.clone())) .merge(recipe::routes(state.clone()))
.merge(session::routes(state.clone())) .merge(session::routes(state.clone()))
.merge(schedule::routes(state.clone())) .merge(schedule::routes(state.clone()))
.merge(project::routes(state.clone()))
} }

View File

@@ -0,0 +1,358 @@
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)
}

View File

@@ -1,4 +1,6 @@
use super::utils::verify_secret_key; use super::utils::verify_secret_key;
use chrono::{DateTime, Datelike};
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use crate::state::AppState; use crate::state::AppState;
@@ -13,6 +15,7 @@ use goose::session;
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder}; use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
use goose::session::SessionMetadata; use goose::session::SessionMetadata;
use serde::Serialize; use serde::Serialize;
use tracing::{error, info};
use utoipa::ToSchema; use utoipa::ToSchema;
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -33,6 +36,29 @@ pub struct SessionHistoryResponse {
messages: Vec<Message>, messages: Vec<Message>,
} }
#[derive(Serialize, ToSchema, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SessionInsights {
/// Total number of sessions
total_sessions: usize,
/// Most active working directories with session counts
most_active_dirs: Vec<(String, usize)>,
/// Average session duration in minutes
avg_session_duration: f64,
/// Total tokens used across all sessions
total_tokens: i64,
/// Activity trend for the last 7 days
recent_activity: Vec<(String, usize)>,
}
#[derive(Serialize, ToSchema, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ActivityHeatmapCell {
pub week: usize,
pub day: usize,
pub count: usize,
}
#[utoipa::path( #[utoipa::path(
get, get,
path = "/sessions", path = "/sessions",
@@ -106,10 +132,174 @@ async fn get_session_history(
})) }))
} }
#[utoipa::path(
get,
path = "/sessions/insights",
responses(
(status = 200, description = "Session insights retrieved successfully", body = SessionInsights),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn get_session_insights(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<SessionInsights>, StatusCode> {
info!("Received request for session insights");
verify_secret_key(&headers, &state)?;
let sessions = get_valid_sorted_sessions(SortOrder::Descending).map_err(|e| {
error!("Failed to get session info: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Filter out sessions without descriptions
let sessions: Vec<SessionInfo> = sessions
.into_iter()
.filter(|session| !session.metadata.description.is_empty())
.collect();
info!("Found {} sessions with descriptions", sessions.len());
// Calculate insights
let total_sessions = sessions.len();
// Debug: Log if we have very few sessions, which might indicate filtering issues
if total_sessions == 0 {
info!("Warning: No sessions found with descriptions");
}
// Track directory usage
let mut dir_counts: HashMap<String, usize> = HashMap::new();
let mut total_duration = 0.0;
let mut total_tokens = 0;
let mut activity_by_date: HashMap<String, usize> = HashMap::new();
for session in &sessions {
// Track directory usage
let dir = session.metadata.working_dir.to_string_lossy().to_string();
*dir_counts.entry(dir).or_insert(0) += 1;
// Track tokens - only add positive values to prevent negative totals
if let Some(tokens) = session.metadata.accumulated_total_tokens {
if tokens > 0 {
total_tokens += tokens as i64;
} else if tokens < 0 {
// Log negative token values for debugging
info!(
"Warning: Session {} has negative accumulated_total_tokens: {}",
session.id, tokens
);
}
}
// Track activity by date
if let Ok(date) = DateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC") {
let date_str = date.format("%Y-%m-%d").to_string();
*activity_by_date.entry(date_str).or_insert(0) += 1;
}
// Calculate session duration from messages
let session_path = session::get_path(session::Identifier::Name(session.id.clone()));
if let Ok(session_path) = session_path {
if let Ok(messages) = session::read_messages(&session_path) {
if let (Some(first), Some(last)) = (messages.first(), messages.last()) {
let duration = (last.created - first.created) as f64 / 60.0; // Convert to minutes
total_duration += duration;
}
}
}
}
// Get top 3 most active directories
let mut dir_vec: Vec<(String, usize)> = dir_counts.into_iter().collect();
dir_vec.sort_by(|a, b| b.1.cmp(&a.1));
let most_active_dirs = dir_vec.into_iter().take(3).collect();
// Calculate average session duration
let avg_session_duration = if total_sessions > 0 {
total_duration / total_sessions as f64
} else {
0.0
};
// Get last 7 days of activity
let mut activity_vec: Vec<(String, usize)> = activity_by_date.into_iter().collect();
activity_vec.sort_by(|a, b| b.0.cmp(&a.0)); // Sort by date descending
let recent_activity = activity_vec.into_iter().take(7).collect();
let insights = SessionInsights {
total_sessions,
most_active_dirs,
avg_session_duration,
total_tokens,
recent_activity,
};
info!("Returning insights: {:?}", insights);
Ok(Json(insights))
}
#[utoipa::path(
get,
path = "/sessions/activity-heatmap",
responses(
(status = 200, description = "Activity heatmap data", body = [ActivityHeatmapCell]),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 500, description = "Internal server error")
),
security(("api_key" = [])),
tag = "Session Management"
)]
async fn get_activity_heatmap(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<Vec<ActivityHeatmapCell>>, StatusCode> {
verify_secret_key(&headers, &state)?;
let sessions = get_valid_sorted_sessions(SortOrder::Descending)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Only sessions with a description
let sessions: Vec<SessionInfo> = sessions
.into_iter()
.filter(|session| !session.metadata.description.is_empty())
.collect();
// Map: (week, day) -> count
let mut heatmap: std::collections::HashMap<(usize, usize), usize> =
std::collections::HashMap::new();
for session in &sessions {
if let Ok(date) =
chrono::NaiveDateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC")
{
let date = date.date();
let week = date.iso_week().week() as usize - 1; // 0-based week
let day = date.weekday().num_days_from_sunday() as usize; // 0=Sun, 6=Sat
*heatmap.entry((week, day)).or_insert(0) += 1;
}
}
let mut result = Vec::new();
for ((week, day), count) in heatmap {
result.push(ActivityHeatmapCell { week, day, count });
}
Ok(Json(result))
}
// Configure routes for this module // Configure routes for this module
pub fn routes(state: Arc<AppState>) -> Router { pub fn routes(state: Arc<AppState>) -> Router {
Router::new() Router::new()
.route("/sessions", get(list_sessions)) .route("/sessions", get(list_sessions))
.route("/sessions/{session_id}", get(get_session_history)) .route("/sessions/{session_id}", get(get_session_history))
.route("/sessions/insights", get(get_session_insights))
.route("/sessions/activity-heatmap", get(get_activity_heatmap))
.with_state(state) .with_state(state)
} }

View File

@@ -4,6 +4,7 @@ pub mod context_mgmt;
pub mod message; pub mod message;
pub mod model; pub mod model;
pub mod permission; pub mod permission;
pub mod project;
pub mod prompt_template; pub mod prompt_template;
pub mod providers; pub mod providers;
pub mod recipe; pub mod recipe;

View File

@@ -0,0 +1,68 @@
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,
};

View File

@@ -0,0 +1,239 @@
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(())
}

View File

@@ -1267,6 +1267,7 @@ async fn run_scheduled_job_internal(
working_dir: current_dir.clone(), working_dir: current_dir.clone(),
description: String::new(), description: String::new(),
schedule_id: Some(job.id.clone()), schedule_id: Some(job.id.clone()),
project_id: None,
message_count: all_session_messages.len(), message_count: all_session_messages.len(),
total_tokens: None, total_tokens: None,
input_tokens: None, input_tokens: None,

View File

@@ -41,6 +41,8 @@ pub struct SessionMetadata {
pub description: String, pub description: String,
/// ID of the schedule that triggered this session, if any /// ID of the schedule that triggered this session, if any
pub schedule_id: Option<String>, 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 /// Number of messages in the session
pub message_count: usize, pub message_count: usize,
/// The total number of tokens used in the session. Retrieved from the provider's last usage. /// The total number of tokens used in the session. Retrieved from the provider's last usage.
@@ -68,6 +70,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
description: String, description: String,
message_count: usize, message_count: usize,
schedule_id: Option<String>, // For backward compatibility schedule_id: Option<String>, // For backward compatibility
project_id: Option<String>, // For backward compatibility
total_tokens: Option<i32>, total_tokens: Option<i32>,
input_tokens: Option<i32>, input_tokens: Option<i32>,
output_tokens: Option<i32>, output_tokens: Option<i32>,
@@ -89,6 +92,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
description: helper.description, description: helper.description,
message_count: helper.message_count, message_count: helper.message_count,
schedule_id: helper.schedule_id, schedule_id: helper.schedule_id,
project_id: helper.project_id,
total_tokens: helper.total_tokens, total_tokens: helper.total_tokens,
input_tokens: helper.input_tokens, input_tokens: helper.input_tokens,
output_tokens: helper.output_tokens, output_tokens: helper.output_tokens,
@@ -113,6 +117,7 @@ impl SessionMetadata {
working_dir, working_dir,
description: String::new(), description: String::new(),
schedule_id: None, schedule_id: None,
project_id: None,
message_count: 0, message_count: 0,
total_tokens: None, total_tokens: None,
input_tokens: None, input_tokens: None,

View File

@@ -32,6 +32,7 @@ pub struct ConfigurableMockScheduler {
sessions_data: Arc<Mutex<HashMap<String, Vec<(String, SessionMetadata)>>>>, sessions_data: Arc<Mutex<HashMap<String, Vec<(String, SessionMetadata)>>>>,
} }
#[allow(dead_code)]
impl ConfigurableMockScheduler { impl ConfigurableMockScheduler {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -404,6 +405,7 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) ->
working_dir: PathBuf::from(working_dir), working_dir: PathBuf::from(working_dir),
description: "Test session".to_string(), description: "Test session".to_string(),
schedule_id: Some("test_job".to_string()), schedule_id: Some("test_job".to_string()),
project_id: None,
total_tokens: Some(100), total_tokens: Some(100),
input_tokens: Some(50), input_tokens: Some(50),
output_tokens: Some(50), output_tokens: Some(50),

View File

@@ -52,7 +52,7 @@ The Goose Desktop App is an Electron application built with TypeScript, React, a
2. Create a main component file (e.g., `YourFeatureView.tsx`) 2. Create a main component file (e.g., `YourFeatureView.tsx`)
3. Add your view type to the `View` type in `App.tsx` 3. Add your view type to the `View` type in `App.tsx`
4. Import and add your component to the render section in `App.tsx` 4. Import and add your component to the render section in `App.tsx`
5. Add navigation to your view from other components (e.g., adding a button in `BottomMenu.tsx` or `MoreMenu.tsx`) 5. Add navigation to your view from other components (e.g., adding a new route or button in `App.tsx`)
## State Management ## State Management

View File

@@ -12,14 +12,14 @@ let cfg = {
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE, certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
signingRole: process.env.WINDOW_SIGNING_ROLE, signingRole: process.env.WINDOW_SIGNING_ROLE,
rfc3161TimeStampServer: 'http://timestamp.digicert.com', rfc3161TimeStampServer: 'http://timestamp.digicert.com',
signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256' signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256',
}, },
// Protocol registration // Protocol registration
protocols: [ protocols: [
{ {
name: "GooseProtocol", name: 'GooseProtocol',
schemes: ["goose"] schemes: ['goose'],
} },
], ],
// macOS Info.plist extensions for drag-and-drop support // macOS Info.plist extensions for drag-and-drop support
extendInfo: { extendInfo: {
@@ -44,9 +44,9 @@ let cfg = {
osxNotarize: { osxNotarize: {
appleId: process.env['APPLE_ID'], appleId: process.env['APPLE_ID'],
appleIdPassword: process.env['APPLE_ID_PASSWORD'], appleIdPassword: process.env['APPLE_ID_PASSWORD'],
teamId: process.env['APPLE_TEAM_ID'] teamId: process.env['APPLE_TEAM_ID'],
}, },
} };
if (process.env['APPLE_ID'] === undefined) { if (process.env['APPLE_ID'] === undefined) {
delete cfg.osxNotarize; delete cfg.osxNotarize;
@@ -62,12 +62,12 @@ module.exports = {
config: { config: {
repository: { repository: {
owner: 'block', owner: 'block',
name: 'goose' name: 'goose',
}, },
prerelease: false, prerelease: false,
draft: true draft: true,
} },
} },
], ],
makers: [ makers: [
{ {
@@ -76,22 +76,22 @@ module.exports = {
config: { config: {
arch: process.env.ELECTRON_ARCH === 'x64' ? ['x64'] : ['arm64'], arch: process.env.ELECTRON_ARCH === 'x64' ? ['x64'] : ['arm64'],
options: { options: {
icon: 'src/images/icon.ico' icon: 'src/images/icon.ico',
} },
} },
}, },
{ {
name: '@electron-forge/maker-deb', name: '@electron-forge/maker-deb',
config: { config: {
name: 'Goose', name: 'Goose',
bin: 'Goose' bin: 'Goose',
}, },
}, },
{ {
name: '@electron-forge/maker-rpm', name: '@electron-forge/maker-rpm',
config: { config: {
name: 'Goose', name: 'Goose',
bin: 'Goose' bin: 'Goose',
}, },
}, },
], ],
@@ -102,17 +102,17 @@ module.exports = {
build: [ build: [
{ {
entry: 'src/main.ts', entry: 'src/main.ts',
config: 'vite.main.config.ts', config: 'vite.main.config.mts',
}, },
{ {
entry: 'src/preload.ts', entry: 'src/preload.ts',
config: 'vite.preload.config.ts', config: 'vite.preload.config.mts',
}, },
], ],
renderer: [ renderer: [
{ {
name: 'main_window', name: 'main_window',
config: 'vite.renderer.config.ts', config: 'vite.renderer.config.mts',
}, },
], ],
}, },

View File

@@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://127.0.0.1:* https://api.github.com https://github.com https://objects.githubusercontent.com; object-src 'none'; frame-src 'none'; font-src 'self' data: https:; media-src 'self' mediastream:; form-action 'none'; base-uri 'self'; manifest-src 'self'; worker-src 'self';" />
<title>Goose</title> <title>Goose</title>
<script> <script>
// Initialize theme before any content loads // Initialize theme before any content loads

View File

@@ -10,7 +10,7 @@
"license": { "license": {
"name": "Apache-2.0" "name": "Apache-2.0"
}, },
"version": "1.0.34" "version": "1.0.35"
}, },
"paths": { "paths": {
"/agent/tools": { "/agent/tools": {
@@ -1564,6 +1564,10 @@
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
}, },
"id": {
"type": "string",
"nullable": true
},
"role": { "role": {
"$ref": "#/components/schemas/Role" "$ref": "#/components/schemas/Role"
} }
@@ -2225,6 +2229,11 @@
"description": "The number of output tokens used in the session. Retrieved from the provider's last usage.", "description": "The number of output tokens used in the session. Retrieved from the provider's last usage.",
"nullable": true "nullable": true
}, },
"project_id": {
"type": "string",
"description": "ID of the project this session belongs to, if any",
"nullable": true
},
"schedule_id": { "schedule_id": {
"type": "string", "type": "string",
"description": "ID of the schedule that triggered this session, if any", "description": "ID of the schedule that triggered this session, if any",

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "goose-app", "name": "goose-app",
"productName": "Goose", "productName": "Goose",
"version": "1.0.36", "version": "1.1.0-alpha.4",
"description": "Goose App", "description": "Goose App",
"engines": { "engines": {
"node": "^22.9.0" "node": "^22.9.0"
@@ -33,6 +33,55 @@
"prepare": "cd ../.. && husky install", "prepare": "cd ../.. && husky install",
"start-alpha-gui": "ALPHA=true npm run start-gui" "start-alpha-gui": "ALPHA=true npm run start-gui"
}, },
"dependencies": {
"@ai-sdk/openai": "^0.0.72",
"@ai-sdk/ui-utils": "^1.0.2",
"@hey-api/client-fetch": "^0.8.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/themes": "^3.1.5",
"@types/react-router-dom": "^5.3.3",
"ai": "^3.4.33",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"compare-versions": "^6.1.1",
"cors": "^2.8.5",
"cronstrue": "^2.48.0",
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"electron-log": "^5.2.2",
"electron-squirrel-startup": "^1.0.1",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"express": "^4.21.1",
"gsap": "^3.13.0",
"lodash": "^4.17.21",
"lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.3.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^7.6.2",
"react-select": "^5.9.0",
"react-syntax-highlighter": "^15.6.1",
"react-toastify": "^10.0.0",
"remark-gfm": "^4.0.1",
"split-type": "^0.3.4",
"tailwind": "^4.0.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.4",
"unist-util-visit": "^5.0.0",
"uuid": "^11.1.0"
},
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.5.0", "@electron-forge/cli": "^7.5.0",
"@electron-forge/maker-deb": "^7.5.0", "@electron-forge/maker-deb": "^7.5.0",
@@ -50,11 +99,17 @@
"@playwright/test": "^1.51.1", "@playwright/test": "^1.51.1",
"@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/electron": "^1.4.38", "@types/electron": "^1.4.38",
"@types/electron-squirrel-startup": "^1.0.2", "@types/electron-squirrel-startup": "^1.0.2",
"@types/electron-window-state": "^2.0.34", "@types/electron-window-state": "^2.0.34",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/lodash": "^4.17.16",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
@@ -67,7 +122,7 @@
"lint-staged": "^15.4.1", "lint-staged": "^15.4.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"tailwindcss": "^3.4.14", "tailwindcss": "^4.1.10",
"typescript": "~5.5.0", "typescript": "~5.5.0",
"vite": "^6.3.4" "vite": "^6.3.4"
}, },
@@ -82,53 +137,5 @@
"src/**/*.{css,json}": [ "src/**/*.{css,json}": [
"prettier --write" "prettier --write"
] ]
},
"dependencies": {
"@ai-sdk/openai": "^0.0.72",
"@ai-sdk/ui-utils": "^1.0.2",
"@hey-api/client-fetch": "^0.8.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/themes": "^3.1.5",
"@types/lodash": "^4.17.16",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/yauzl": "^2.10.3",
"ai": "^3.4.33",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"compare-versions": "^6.1.1",
"cors": "^2.8.5",
"cronstrue": "^2.48.0",
"dotenv": "^16.4.5",
"electron-log": "^5.2.2",
"electron-squirrel-startup": "^1.0.1",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"express": "^4.21.1",
"framer-motion": "^11.11.11",
"lodash": "^4.17.21",
"lucide-react": "^0.475.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.3.0",
"react-markdown": "^9.0.1",
"react-select": "^5.9.0",
"react-syntax-highlighter": "^15.6.1",
"react-toastify": "^8.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
"uuid": "^11.1.0"
} }
} }

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -12,7 +12,7 @@ async function buildMain() {
} }
await build({ await build({
configFile: resolve(__dirname, '../vite.main.config.ts'), configFile: resolve(__dirname, '../vite.main.config.mts'),
build: { build: {
outDir, outDir,
emptyOutDir: false, emptyOutDir: false,

File diff suppressed because it is too large Load Diff

View File

@@ -211,6 +211,7 @@ export type ListSchedulesResponse = {
export type Message = { export type Message = {
content: Array<MessageContent>; content: Array<MessageContent>;
created: number; created: number;
id?: string | null;
role: Role; role: Role;
}; };
@@ -428,6 +429,10 @@ export type SessionMetadata = {
* The number of output tokens used in the session. Retrieved from the provider's last usage. * The number of output tokens used in the session. Retrieved from the provider's last usage.
*/ */
output_tokens?: number | null; 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 * ID of the schedule that triggered this session, if any
*/ */

View File

@@ -2,13 +2,21 @@ interface AgentHeaderProps {
title: string; title: string;
profileInfo?: string; profileInfo?: string;
onChangeProfile?: () => void; onChangeProfile?: () => void;
showBorder?: boolean;
} }
export function AgentHeader({ title, profileInfo, onChangeProfile }: AgentHeaderProps) { export function AgentHeader({
title,
profileInfo,
onChangeProfile,
showBorder = false,
}: AgentHeaderProps) {
return ( return (
<div className="flex items-center justify-between px-4 py-2 border-b border-borderSubtle"> <div
<div className="flex items-center"> className={`flex items-center justify-between px-4 py-2 ${showBorder ? 'border-b border-borderSubtle' : ''}`}
<span className="w-2 h-2 rounded-full bg-blockTeal mr-2" /> >
<div className="flex items-center ml-6">
<span className="w-2 h-2 rounded-full bg-green-500 mr-2" />
<span className="text-sm"> <span className="text-sm">
<span className="text-textSubtle">Agent</span>{' '} <span className="text-textSubtle">Agent</span>{' '}
<span className="text-textStandard">{title}</span> <span className="text-textStandard">{title}</span>

View File

@@ -0,0 +1,525 @@
/**
* BaseChat Component
*
* BaseChat is the foundational chat component that provides the core conversational interface
* for the Goose Desktop application. It serves as the shared base for both Hub and Pair components,
* offering a flexible and extensible chat experience.
*
* Key Responsibilities:
* - Manages the complete chat lifecycle (messages, input, submission, responses)
* - Handles file drag-and-drop functionality with preview generation
* - Integrates with multiple specialized hooks for chat engine, recipes, sessions, etc.
* - Provides context management and session summarization capabilities
* - Supports both user and assistant message rendering with tool call integration
* - Manages loading states, error handling, and retry functionality
* - Offers customization points through render props and configuration options
*
* Architecture:
* - Uses a provider pattern (ChatContextManagerProvider) for state management
* - Leverages composition through render props for flexible UI customization
* - Integrates with multiple custom hooks for separation of concerns:
* - useChatEngine: Core chat functionality and API integration
* - useRecipeManager: Recipe/agent configuration management
* - useSessionContinuation: Session persistence and resumption
* - useFileDrop: Drag-and-drop file handling with previews
* - useCostTracking: Token usage and cost calculation
*
* Customization Points:
* - renderHeader(): Custom header content (used by Hub for insights/recipe controls)
* - renderBeforeMessages(): Content before message list (used by Hub for SessionInsights)
* - renderAfterMessages(): Content after message list
* - customChatInputProps: Props passed to ChatInput for specialized behavior
* - customMainLayoutProps: Props passed to MainPanelLayout
* - contentClassName: Custom CSS classes for the content area
*
* File Handling:
* - Supports drag-and-drop of files with visual feedback
* - Generates image previews for supported file types
* - Integrates dropped files with chat input for seamless attachment
* - Uses data-drop-zone="true" to designate safe drop areas
*
* The component is designed to be the single source of truth for chat functionality
* while remaining flexible enough to support different UI contexts (Hub vs Pair).
*/
import React, { useEffect, useContext, createContext, useRef, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import LoadingGoose from './LoadingGoose';
import Splash from './Splash';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import {
ChatContextManagerProvider,
useChatContextManager,
} from './context_management/ChatContextManager';
import { type View, ViewOptions } from '../App';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
import { useChatEngine } from '../hooks/useChatEngine';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useSessionContinuation } from '../hooks/useSessionContinuation';
import { useFileDrop } from '../hooks/useFileDrop';
import { useCostTracking } from '../hooks/useCostTracking';
import { Message } from '../types/message';
import { Recipe } from '../recipe';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
export const useCurrentModelInfo = () => useContext(CurrentModelContext);
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
}
interface BaseChatProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
enableLocalStorage?: boolean;
onMessageStreamFinish?: () => void;
onMessageSubmit?: (message: string) => void; // Callback after message is submitted
renderHeader?: () => React.ReactNode;
renderBeforeMessages?: () => React.ReactNode;
renderAfterMessages?: () => React.ReactNode;
customChatInputProps?: Record<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
contentClassName?: string; // Add custom class for content area
disableSearch?: boolean; // Disable search functionality (for Hub)
showPopularTopics?: boolean; // Show popular chat topics in empty state (for Pair)
suppressEmptyState?: boolean; // Suppress empty state content (for transitions)
}
function BaseChatContent({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
enableLocalStorage = false,
onMessageStreamFinish,
onMessageSubmit,
renderHeader,
renderBeforeMessages,
renderAfterMessages,
customChatInputProps = {},
customMainLayoutProps = {},
contentClassName = '',
disableSearch = false,
showPopularTopics = false,
suppressEmptyState = false,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
// Get disableAnimation from location state
const disableAnimation = location.state?.disableAnimation || false;
// Track if user has started using the current recipe
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
const {
summaryContent,
summarizedThread,
isSummaryModalOpen,
isLoadingSummary,
resetMessagesWithSummary,
closeSummaryModal,
updateSummary,
} = useChatContextManager();
// Use shared chat engine
const {
messages,
filteredMessages,
ancestorMessages,
setAncestorMessages,
append,
isLoading,
error,
setMessages,
input: _input,
setInput: _setInput,
handleSubmit: engineHandleSubmit,
onStopGoose,
sessionTokenCount,
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
commandHistory,
toolCallNotifications,
updateMessageStreamBody,
sessionMetadata,
isUserMessage,
} = useChatEngine({
chat,
setChat,
onMessageStreamFinish: () => {
// Auto-scroll to bottom when message stream finishes
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 300);
// Call the original callback if provided
onMessageStreamFinish?.();
},
onMessageSent: () => {
// Mark that user has started using the recipe
if (recipeConfig) {
setHasStartedUsingRecipe(true);
}
// Create new session after message is sent if needed
createNewSessionIfNeeded();
},
enableLocalStorage,
});
// Use shared recipe manager
const {
recipeConfig,
initialPrompt,
isGeneratingRecipe,
handleAutoExecution,
recipeError,
setRecipeError,
} = useRecipeManager(messages, location.state);
// Reset recipe usage tracking when recipe changes
useEffect(() => {
if (recipeConfig?.title !== currentRecipeTitle) {
setCurrentRecipeTitle(recipeConfig?.title || null);
setHasStartedUsingRecipe(false);
// Clear existing messages when a new recipe is loaded
if (recipeConfig?.title && recipeConfig.title !== currentRecipeTitle) {
setMessages([]);
setAncestorMessages([]);
}
}
}, [recipeConfig?.title, currentRecipeTitle, setMessages, setAncestorMessages]);
// Handle recipe auto-execution
useEffect(() => {
handleAutoExecution(append, isLoading);
}, [handleAutoExecution, append, isLoading]);
// Use shared session continuation
const { createNewSessionIfNeeded } = useSessionContinuation({
chat,
setChat,
summarizedThread,
updateMessageStreamBody,
});
// Use shared file drop
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
// Use shared cost tracking
const { sessionCosts } = useCostTracking({
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
sessionMetadata,
});
useEffect(() => {
// Log all messages when the component first mounts
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array means this runs once on mount
// Handle submit with summary reset support
const handleSubmit = (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const combinedTextFromInput = customEvent.detail?.value || '';
// Mark that user has started using the recipe when they submit a message
if (recipeConfig && combinedTextFromInput.trim()) {
setHasStartedUsingRecipe(true);
}
const onSummaryReset =
summarizedThread.length > 0
? () => {
resetMessagesWithSummary(
messages,
setMessages,
ancestorMessages,
setAncestorMessages,
summaryContent
);
}
: undefined;
// Call the callback if provided (for Hub to handle navigation)
if (onMessageSubmit && combinedTextFromInput.trim()) {
onMessageSubmit(combinedTextFromInput);
}
engineHandleSubmit(combinedTextFromInput, onSummaryReset);
// Auto-scroll to bottom after submitting
if (onSummaryReset) {
// If we're resetting with summary, delay the scroll a bit more
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 200);
} else {
// Immediate scroll for regular submit
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}
};
// Wrapper for append that tracks recipe usage
const appendWithTracking = (text: string | Message) => {
// Mark that user has started using the recipe when they use append
if (recipeConfig) {
setHasStartedUsingRecipe(true);
}
append(text);
};
// Callback to handle scroll to bottom from ProgressiveMessageList
const handleScrollToBottom = useCallback(() => {
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 100);
}, []);
return (
<div className="h-full flex flex-col min-h-0">
<MainPanelLayout
backgroundColor={'bg-background-muted'}
removeTopPadding={true}
{...customMainLayoutProps}
>
{/* Loader when generating recipe */}
{isGeneratingRecipe && <LayingEggLoader />}
{/* Custom header */}
{renderHeader && renderHeader()}
{/* Chat container with sticky recipe header */}
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
<ScrollArea
ref={scrollRef}
className={`flex-1 bg-background-default rounded-b-2xl min-h-0 relative ${contentClassName}`}
autoScroll
onDrop={handleDrop}
onDragOver={handleDragOver}
data-drop-zone="true"
paddingX={6}
paddingY={0}
>
{/* Recipe agent header - sticky at top of chat container */}
{recipeConfig?.title && (
<div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">
<AgentHeader
title={recipeConfig.title}
profileInfo={
recipeConfig.profile
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
: undefined
}
onChangeProfile={() => {
console.log('Change profile clicked');
}}
showBorder={true}
/>
</div>
)}
{/* Custom content before messages */}
{renderBeforeMessages && renderBeforeMessages()}
{/* Messages or Splash or Popular Topics */}
{
// Check if we should show splash instead of messages
(() => {
// Show splash if we have a recipe and user hasn't started using it yet
const shouldShowSplash =
recipeConfig && !hasStartedUsingRecipe && !suppressEmptyState;
return shouldShowSplash;
})() ? (
<>
{/* Show Splash when we have a recipe config and user hasn't started using it */}
{recipeConfig ? (
<Splash
append={(text: string) => appendWithTracking(text)}
activities={
Array.isArray(recipeConfig.activities) ? recipeConfig.activities : null
}
title={recipeConfig.title}
/>
) : showPopularTopics ? (
/* Show PopularChatTopics when no real messages, no recipe, and showPopularTopics is true (Pair view) */
<PopularChatTopics append={(text: string) => appendWithTracking(text)} />
) : null}
</>
) : filteredMessages.length > 0 || (recipeConfig && hasStartedUsingRecipe) ? (
<>
{disableSearch ? (
// Render messages without SearchView wrapper when search is disabled
<ProgressiveMessageList
messages={filteredMessages}
chat={chat}
toolCallNotifications={toolCallNotifications}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
isUserMessage={isUserMessage}
onScrollToBottom={handleScrollToBottom}
isStreamingMessage={isLoading}
/>
) : (
// Render messages with SearchView wrapper when search is enabled
<SearchView>
<ProgressiveMessageList
messages={filteredMessages}
chat={chat}
toolCallNotifications={toolCallNotifications}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
isUserMessage={isUserMessage}
onScrollToBottom={handleScrollToBottom}
isStreamingMessage={isLoading}
/>
</SearchView>
)}
{error && (
<div className="flex flex-col items-center justify-center p-4">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
{error.message || 'Honk! Goose experienced an error while responding'}
</div>
<div
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
// Find the last user message
const lastUserMessage = messages.reduceRight(
(found, m) => found || (m.role === 'user' ? m : null),
null as Message | null
);
if (lastUserMessage) {
append(lastUserMessage);
}
}}
>
Retry Last Message
</div>
</div>
)}
<div className="block h-8" />
</>
) : showPopularTopics ? (
/* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */
<PopularChatTopics append={(text: string) => append(text)} />
) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */
}
{/* Custom content after messages */}
{renderAfterMessages && renderAfterMessages()}
{/* Bottom padding to make space for the loading indicator */}
<div className="block h-12" />
</ScrollArea>
{/* Fixed loading indicator at bottom left of chat container */}
{isLoading && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose message={isLoadingSummary ? 'summarizing conversation…' : undefined} />
</div>
)}
</div>
<div
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<ChatInput
handleSubmit={handleSubmit}
isLoading={isLoading}
onStop={onStopGoose}
commandHistory={commandHistory}
initialValue={_input || initialPrompt}
setView={setView}
numTokens={sessionTokenCount}
inputTokens={sessionInputTokens || localInputTokens}
outputTokens={sessionOutputTokens || localOutputTokens}
droppedFiles={droppedFiles}
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
messages={messages}
setMessages={setMessages}
disableAnimation={disableAnimation}
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipeConfig={recipeConfig}
{...customChatInputProps}
/>
</div>
</MainPanelLayout>
<SessionSummaryModal
isOpen={isSummaryModalOpen}
onClose={closeSummaryModal}
onSave={(editedContent) => {
updateSummary(editedContent);
closeSummaryModal();
}}
summaryContent={summaryContent}
/>
{/* Recipe Error Modal */}
{recipeError && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Recipe Creation Failed</h3>
<p className="text-textStandard mb-6">{recipeError}</p>
<div className="flex justify-end">
<button
onClick={() => setRecipeError(null)}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors"
>
OK
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default function BaseChat(props: BaseChatProps) {
return (
<ChatContextManagerProvider>
<BaseChatContent {...props} />
</ChatContextManagerProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,905 +0,0 @@
import React, {
useEffect,
useRef,
useState,
useMemo,
useCallback,
createContext,
useContext,
} from 'react';
import { getApiUrl } from '../config';
import FlappyGoose from './FlappyGoose';
import GooseMessage from './GooseMessage';
import ChatInput from './ChatInput';
import { type View, ViewOptions } from '../App';
import LoadingGoose from './LoadingGoose';
import MoreMenuLayout from './more_menu/MoreMenuLayout';
import { Card } from './ui/card';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
import UserMessage from './UserMessage';
import Splash from './Splash';
import { SearchView } from './conversation/SearchView';
import { createRecipe } from '../recipe';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import { fetchSessionDetails, generateSessionId } from '../sessions';
import 'react-toastify/dist/ReactToastify.css';
import { useMessageStream } from '../hooks/useMessageStream';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import ParameterInputModal from './ParameterInputModal';
import { Recipe } from '../recipe';
import {
ChatContextManagerProvider,
useChatContextManager,
} from './context_management/ChatContextManager';
import { ContextHandler } from './context_management/ContextHandler';
import { LocalMessageStorage } from '../utils/localMessageStorage';
import { useModelAndProvider } from './ModelAndProviderContext';
import { getCostForModel } from '../utils/costDatabase';
import { updateSystemPromptWithParameters } from '../utils/providerUtils';
import {
Message,
createUserMessage,
ToolCall,
ToolCallResult,
ToolRequestMessageContent,
ToolResponseMessageContent,
ToolConfirmationRequestMessageContent,
getTextContent,
TextContent,
} from '../types/message';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
export const useCurrentModelInfo = () => useContext(CurrentModelContext);
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
}
// Helper function to determine if a message is a user message
const isUserMessage = (message: Message): boolean => {
if (message.role === 'assistant') {
return false;
}
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
return false;
}
return true;
};
const substituteParameters = (prompt: string, params: Record<string, string>): string => {
let substitutedPrompt = prompt;
for (const key in params) {
// Escape special characters in the key (parameter) and match optional whitespace
const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g');
substitutedPrompt = substitutedPrompt.replace(regex, params[key]);
}
return substitutedPrompt;
};
export default function ChatView({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
return (
<ChatContextManagerProvider>
<ChatContent
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
</ChatContextManagerProvider>
);
}
function ChatContent({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
const [hasMessages, setHasMessages] = useState(false);
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [showGame, setShowGame] = useState(false);
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
const [sessionInputTokens, setSessionInputTokens] = useState<number>(0);
const [sessionOutputTokens, setSessionOutputTokens] = useState<number>(0);
const [localInputTokens, setLocalInputTokens] = useState<number>(0);
const [localOutputTokens, setLocalOutputTokens] = useState<number>(0);
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
const [isParameterModalOpen, setIsParameterModalOpen] = useState(false);
const [recipeParameters, setRecipeParameters] = useState<Record<string, string> | null>(null);
const [sessionCosts, setSessionCosts] = useState<{
[key: string]: {
inputTokens: number;
outputTokens: number;
totalCost: number;
};
}>({});
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
const scrollRef = useRef<ScrollAreaHandle>(null);
const { currentModel, currentProvider } = useModelAndProvider();
const prevModelRef = useRef<string | undefined>();
const prevProviderRef = useRef<string | undefined>();
const {
summaryContent,
summarizedThread,
isSummaryModalOpen,
resetMessagesWithSummary,
closeSummaryModal,
updateSummary,
hasContextHandlerContent,
getContextHandlerType,
} = useChatContextManager();
useEffect(() => {
// Log all messages when the component first mounts
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
);
// Set ready for auto user prompt after component initialization
setReadyForAutoUserPrompt(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array means this runs once on mount;
// Get recipeConfig directly from appConfig
const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;
// Show parameter modal if recipe has parameters and they haven't been set yet
useEffect(() => {
if (recipeConfig?.parameters && recipeConfig.parameters.length > 0) {
// If we have parameters and they haven't been set yet, open the modal.
if (!recipeParameters) {
setIsParameterModalOpen(true);
}
}
}, [recipeConfig, recipeParameters]);
// Store message in global history when it's added
const storeMessageInHistory = useCallback((message: Message) => {
if (isUserMessage(message)) {
const text = getTextContent(message);
if (text) {
LocalMessageStorage.addMessage(text);
}
}
}, []);
const {
messages,
append: originalAppend,
stop,
isLoading,
error,
setMessages,
input: _input,
setInput: _setInput,
handleInputChange: _handleInputChange,
handleSubmit: _submitMessage,
updateMessageStreamBody,
notifications,
currentModelInfo,
sessionMetadata,
} = useMessageStream({
api: getApiUrl('/reply'),
initialMessages: chat.messages,
body: {
session_id: chat.id,
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
...(recipeConfig?.scheduledJobId && { scheduled_job_id: recipeConfig.scheduledJobId }),
},
onFinish: async (_message, _reason) => {
window.electron.stopPowerSaveBlocker();
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 300);
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
window.electron.logInfo('last interaction:' + lastInteractionTime);
if (timeSinceLastInteraction > 60000) {
// 60000ms = 1 minute
window.electron.showNotification({
title: 'Goose finished the task.',
body: 'Click here to expand.',
});
}
},
});
// Wrap append to store messages in global history
const append = useCallback(
(messageOrString: Message | string) => {
const message =
typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString;
storeMessageInHistory(message);
return originalAppend(message);
},
[originalAppend, storeMessageInHistory]
);
// for CLE events -- create a new session id for the next set of messages
useEffect(() => {
// If we're in a continuation session, update the chat ID
if (summarizedThread.length > 0) {
const newSessionId = generateSessionId();
// Update the session ID in the chat object
setChat({
...chat,
id: newSessionId!,
title: `Continued from ${chat.id}`,
messageHistoryIndex: summarizedThread.length,
});
// Update the body used by useMessageStream to send future messages to the new session
if (summarizedThread.length > 0 && updateMessageStreamBody) {
updateMessageStreamBody({
session_id: newSessionId,
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
});
}
}
// only update if summarizedThread length changes from 0 -> 1+
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
summarizedThread.length > 0,
]);
// Listen for make-agent-from-chat event
useEffect(() => {
const handleMakeAgent = async () => {
window.electron.logInfo('Making recipe from chat...');
setIsGeneratingRecipe(true);
try {
// Create recipe directly from chat messages
const createRecipeRequest = {
messages: messages,
title: '',
description: '',
};
const response = await createRecipe(createRecipeRequest);
if (response.error) {
throw new Error(`Failed to create recipe: ${response.error}`);
}
window.electron.logInfo('Created recipe:');
window.electron.logInfo(JSON.stringify(response.recipe, null, 2));
// First, verify the recipe data
if (!response.recipe) {
throw new Error('No recipe data received');
}
// Create a new window for the recipe editor
console.log('Opening recipe editor with config:', response.recipe);
const recipeConfig = {
id: response.recipe.title || 'untitled',
name: response.recipe.title || 'Untitled Recipe', // Does not exist on recipe type
title: response.recipe.title || 'Untitled Recipe',
description: response.recipe.description || '',
parameters: response.recipe.parameters || [],
instructions: response.recipe.instructions || '',
activities: response.recipe.activities || [],
prompt: response.recipe.prompt || '',
};
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
recipeConfig, // recipe config
'recipeEditor' // view type
);
window.electron.logInfo('Opening recipe editor window');
} catch (error) {
window.electron.logInfo('Failed to create recipe:');
const errorMessage = error instanceof Error ? error.message : String(error);
window.electron.logInfo(errorMessage);
} finally {
setIsGeneratingRecipe(false);
}
};
window.addEventListener('make-agent-from-chat', handleMakeAgent);
return () => {
window.removeEventListener('make-agent-from-chat', handleMakeAgent);
};
}, [messages]);
// Update chat messages when they change and save to sessionStorage
useEffect(() => {
// @ts-expect-error - TypeScript being overly strict about the return type
setChat((prevChat: ChatType) => ({ ...prevChat, messages }));
}, [messages, setChat]);
useEffect(() => {
if (messages.length > 0) {
setHasMessages(true);
}
}, [messages]);
// Pre-fill input with recipe prompt instead of auto-sending it
const initialPrompt = useMemo(() => {
if (!recipeConfig?.prompt) return '';
const hasRequiredParams = recipeConfig.parameters && recipeConfig.parameters.length > 0;
// If params are required and have been collected, substitute them into the prompt.
if (hasRequiredParams && recipeParameters) {
return substituteParameters(recipeConfig.prompt, recipeParameters);
}
// If there are no parameters, return the original prompt.
if (!hasRequiredParams) {
return recipeConfig.prompt;
}
// Otherwise, we are waiting for parameters, so the input should be empty.
return '';
}, [recipeConfig, recipeParameters]);
// Auto-send the prompt for scheduled executions
useEffect(() => {
const hasRequiredParams = recipeConfig?.parameters && recipeConfig.parameters.length > 0;
if (
recipeConfig?.isScheduledExecution &&
recipeConfig?.prompt &&
(!hasRequiredParams || recipeParameters) &&
messages.length === 0 &&
!isLoading &&
readyForAutoUserPrompt
) {
// Substitute parameters if they exist
const finalPrompt = recipeParameters
? substituteParameters(recipeConfig.prompt, recipeParameters)
: recipeConfig.prompt;
console.log('Auto-sending substituted prompt for scheduled execution:', finalPrompt);
const userMessage = createUserMessage(finalPrompt);
setLastInteractionTime(Date.now());
window.electron.startPowerSaveBlocker();
append(userMessage);
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 100);
}
}, [
recipeConfig?.isScheduledExecution,
recipeConfig?.prompt,
recipeConfig?.parameters,
recipeParameters,
messages.length,
isLoading,
readyForAutoUserPrompt,
append,
setLastInteractionTime,
]);
const handleParameterSubmit = async (inputValues: Record<string, string>) => {
setRecipeParameters(inputValues);
setIsParameterModalOpen(false);
// Update the system prompt with parameter-substituted instructions
try {
await updateSystemPromptWithParameters(inputValues);
} catch (error) {
console.error('Failed to update system prompt with parameters:', error);
}
};
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
window.electron.startPowerSaveBlocker();
const customEvent = e as unknown as CustomEvent;
// ChatInput now sends a single 'value' field with text and appended image paths
const combinedTextFromInput = customEvent.detail?.value || '';
if (combinedTextFromInput.trim()) {
setLastInteractionTime(Date.now());
// createUserMessage was reverted to only accept text.
// It will create a Message with a single TextContent part containing text + paths.
const userMessage = createUserMessage(combinedTextFromInput.trim());
if (summarizedThread.length > 0) {
resetMessagesWithSummary(
messages,
setMessages,
ancestorMessages,
setAncestorMessages,
summaryContent
);
setTimeout(() => {
append(userMessage);
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 150);
} else {
append(userMessage);
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}
} else {
// If nothing was actually submitted (e.g. empty input and no images pasted)
window.electron.stopPowerSaveBlocker();
}
};
if (error) {
console.log('Error:', error);
}
const onStopGoose = () => {
stop();
setLastInteractionTime(Date.now());
window.electron.stopPowerSaveBlocker();
// Handle stopping the message stream
const lastMessage = messages[messages.length - 1];
// check if the last user message has any tool response(s)
const isToolResponse = lastMessage.content.some(
(content): content is ToolResponseMessageContent => content.type == 'toolResponse'
);
// isUserMessage also checks if the message is a toolConfirmationRequest
// check if the last message is a real user's message
if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) {
// Get the text content from the last message before removing it
const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text');
const textValue = textContent?.text || '';
// Set the text back to the input field
_setInput(textValue);
// Remove the last user message if it's the most recent one
if (messages.length > 1) {
setMessages(messages.slice(0, -1));
} else {
setMessages([]);
}
// Interruption occured after a tool has completed, but no assistant reply
// handle his if we want to popup a message too the user
// } else if (lastMessage && isUserMessage(lastMessage) && isToolResponse) {
} else if (!isUserMessage(lastMessage)) {
// the last message was an assistant message
// check if we have any tool requests or tool confirmation requests
const toolRequests: [string, ToolCallResult<ToolCall>][] = lastMessage.content
.filter(
(content): content is ToolRequestMessageContent | ToolConfirmationRequestMessageContent =>
content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
)
.map((content) => {
if (content.type === 'toolRequest') {
return [content.id, content.toolCall];
} else {
// extract tool call from confirmation
const toolCall: ToolCallResult<ToolCall> = {
status: 'success',
value: {
name: content.toolName,
arguments: content.arguments,
},
};
return [content.id, toolCall];
}
});
if (toolRequests.length !== 0) {
// This means we were interrupted during a tool request
// Create tool responses for all interrupted tool requests
let responseMessage: Message = {
display: true,
sendToLLM: true,
role: 'user',
created: Date.now(),
content: [],
};
const notification = 'Interrupted by the user to make a correction';
// generate a response saying it was interrupted for each tool request
for (const [reqId, _] of toolRequests) {
const toolResponse: ToolResponseMessageContent = {
type: 'toolResponse',
id: reqId,
toolResult: {
status: 'error',
error: notification,
},
};
responseMessage.content.push(toolResponse);
}
// Use an immutable update to add the response message to the messages array
setMessages([...messages, responseMessage]);
}
}
};
// Filter out standalone tool response messages for rendering
// They will be shown as part of the tool invocation in the assistant message
const filteredMessages = [...ancestorMessages, ...messages].filter((message) => {
// Only filter out when display is explicitly false
if (message.display === false) return false;
// Keep all assistant messages and user messages that aren't just tool responses
if (message.role === 'assistant') return true;
// For user messages, check if they're only tool responses
if (message.role === 'user') {
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
const hasTextContent = message.content.some((c) => c.type === 'text');
const hasToolConfirmation = message.content.every(
(c) => c.type === 'toolConfirmationRequest'
);
// Keep the message if it has text content or tool confirmation or is not just tool responses
return hasTextContent || !hasOnlyToolResponses || hasToolConfirmation;
}
return true;
});
const commandHistory = useMemo(() => {
return filteredMessages
.reduce<string[]>((history, message) => {
if (isUserMessage(message)) {
const textContent = message.content.find((c): c is TextContent => c.type === 'text');
const text = textContent?.text?.trim();
if (text) {
history.push(text);
}
}
return history;
}, [])
.reverse();
}, [filteredMessages]);
// Simple token estimation function (roughly 4 characters per token)
const estimateTokens = (text: string): number => {
return Math.ceil(text.length / 4);
};
// Calculate token counts from messages
useEffect(() => {
let inputTokens = 0;
let outputTokens = 0;
messages.forEach((message) => {
const textContent = getTextContent(message);
if (textContent) {
const tokens = estimateTokens(textContent);
if (message.role === 'user') {
inputTokens += tokens;
} else if (message.role === 'assistant') {
outputTokens += tokens;
}
}
});
setLocalInputTokens(inputTokens);
setLocalOutputTokens(outputTokens);
}, [messages]);
// Fetch session metadata to get token count
useEffect(() => {
const fetchSessionTokens = async () => {
try {
const sessionDetails = await fetchSessionDetails(chat.id);
setSessionTokenCount(sessionDetails.metadata.total_tokens || 0);
setSessionInputTokens(sessionDetails.metadata.accumulated_input_tokens || 0);
setSessionOutputTokens(sessionDetails.metadata.accumulated_output_tokens || 0);
} catch (err) {
console.error('Error fetching session token count:', err);
}
};
if (chat.id) {
fetchSessionTokens();
}
}, [chat.id, messages]);
// Update token counts when sessionMetadata changes from the message stream
useEffect(() => {
console.log('Session metadata received:', sessionMetadata);
if (sessionMetadata) {
setSessionTokenCount(sessionMetadata.totalTokens || 0);
setSessionInputTokens(sessionMetadata.accumulatedInputTokens || 0);
setSessionOutputTokens(sessionMetadata.accumulatedOutputTokens || 0);
}
}, [sessionMetadata]);
// Handle model changes and accumulate costs
useEffect(() => {
if (
prevModelRef.current !== undefined &&
prevProviderRef.current !== undefined &&
(prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider)
) {
// Model/provider has changed, save the costs for the previous model
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
// Get pricing info for the previous model
const prevCostInfo = getCostForModel(prevProviderRef.current, prevModelRef.current);
if (prevCostInfo) {
const prevInputCost =
(sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0);
const prevOutputCost =
(sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0);
const prevTotalCost = prevInputCost + prevOutputCost;
// Save the accumulated costs for this model
setSessionCosts((prev) => ({
...prev,
[prevKey]: {
inputTokens: sessionInputTokens || localInputTokens,
outputTokens: sessionOutputTokens || localOutputTokens,
totalCost: prevTotalCost,
},
}));
}
// Restore token counters from session metadata instead of resetting to 0
// This preserves the accumulated session tokens when switching models
// and ensures cost tracking remains accurate across model changes
if (sessionMetadata) {
// Use Math.max to ensure non-negative values and handle potential data issues
setSessionTokenCount(Math.max(0, sessionMetadata.totalTokens || 0));
setSessionInputTokens(Math.max(0, sessionMetadata.accumulatedInputTokens || 0));
setSessionOutputTokens(Math.max(0, sessionMetadata.accumulatedOutputTokens || 0));
} else {
// Fallback: if no session metadata, preserve current session tokens instead of resetting
// This handles edge cases where metadata might not be available yet
console.warn(
'No session metadata available during model change, preserving current tokens'
);
}
// Only reset local token estimation counters since they're model-specific
setLocalInputTokens(0);
setLocalOutputTokens(0);
console.log(
'Model changed from',
`${prevProviderRef.current}/${prevModelRef.current}`,
'to',
`${currentProvider}/${currentModel}`,
'- saved costs and restored session token counters'
);
}
prevModelRef.current = currentModel || undefined;
prevProviderRef.current = currentProvider || undefined;
}, [
currentModel,
currentProvider,
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
sessionMetadata,
]);
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const files = e.dataTransfer.files;
if (files.length > 0) {
const paths: string[] = [];
for (let i = 0; i < files.length; i++) {
paths.push(window.electron.getPathForFile(files[i]));
}
setDroppedFiles(paths);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const toolCallNotifications = notifications.reduce((map, item) => {
const key = item.request_id;
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(item);
return map;
}, new Map());
return (
<CurrentModelContext.Provider value={currentModelInfo}>
<div className="flex flex-col w-full h-screen items-center justify-center">
{/* Loader when generating recipe */}
{isGeneratingRecipe && <LayingEggLoader />}
<MoreMenuLayout
hasMessages={hasMessages}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
<Card
className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{recipeConfig?.title && messages.length > 0 && (
<AgentHeader
title={recipeConfig.title}
profileInfo={
recipeConfig.profile
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
: undefined
}
onChangeProfile={() => {
// Handle profile change
console.log('Change profile clicked');
}}
/>
)}
{messages.length === 0 ? (
<Splash
append={append}
activities={Array.isArray(recipeConfig?.activities) ? recipeConfig!.activities : null}
title={recipeConfig?.title}
/>
) : (
<ScrollArea ref={scrollRef} className="flex-1" autoScroll>
<SearchView>
{filteredMessages.map((message, index) => (
<div
key={(message.id && `${message.id}-${message.content.length}`) || index}
className="mt-4 px-4"
data-testid="message-container"
>
{isUserMessage(message) ? (
<>
{hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType(message)}
/>
) : (
<UserMessage message={message} />
)}
</>
) : (
<>
{/* Only render GooseMessage if it's not a message invoking some context management */}
{hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType(message)}
/>
) : (
<GooseMessage
messageHistoryIndex={chat?.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
toolCallNotifications={toolCallNotifications}
/>
)}
</>
)}
</div>
))}
</SearchView>
{error && (
<div className="flex flex-col items-center justify-center p-4">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
{error.message || 'Honk! Goose experienced an error while responding'}
</div>
<div
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
onClick={async () => {
// Find the last user message
const lastUserMessage = messages.reduceRight(
(found, m) => found || (m.role === 'user' ? m : null),
null as Message | null
);
if (lastUserMessage) {
append(lastUserMessage);
}
}}
>
Retry Last Message
</div>
</div>
)}
<div className="block h-8" />
</ScrollArea>
)}
<div className="relative p-4 pt-0 z-10 animate-[fadein_400ms_ease-in_forwards]">
{isLoading && <LoadingGoose />}
<ChatInput
handleSubmit={handleSubmit}
isLoading={isLoading}
onStop={onStopGoose}
commandHistory={commandHistory}
initialValue={_input || (hasMessages ? _input : initialPrompt)}
setView={setView}
hasMessages={hasMessages}
numTokens={sessionTokenCount}
inputTokens={sessionInputTokens || localInputTokens}
outputTokens={sessionOutputTokens || localOutputTokens}
droppedFiles={droppedFiles}
messages={messages}
setMessages={setMessages}
sessionCosts={sessionCosts}
/>
</div>
</Card>
{showGame && <FlappyGoose onClose={() => setShowGame(false)} />}
<SessionSummaryModal
isOpen={isSummaryModalOpen}
onClose={closeSummaryModal}
onSave={(editedContent) => {
updateSummary(editedContent);
closeSummaryModal();
}}
summaryContent={summaryContent}
/>
{isParameterModalOpen && recipeConfig?.parameters && (
<ParameterInputModal
parameters={recipeConfig.parameters}
onSubmit={handleParameterSubmit}
onClose={() => setIsParameterModalOpen(false)}
/>
)}
</div>
</CurrentModelContext.Provider>
);
}

View File

@@ -38,12 +38,7 @@ export function ErrorUI({ error }: { error: Error }) {
{error.message} {error.message}
</pre> </pre>
<Button <Button onClick={() => window.electron.reloadApp()}>Reload</Button>
className="flex items-center gap-2 flex-1 justify-center text-white dark:text-background bg-black dark:bg-foreground hover:bg-subtle dark:hover:bg-muted"
onClick={() => window.electron.reloadApp()}
>
Reload
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -6,11 +6,21 @@ interface FileIconProps {
className?: string; className?: string;
} }
export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, className = "w-4 h-4" }) => { export const FileIcon: React.FC<FileIconProps> = ({
fileName,
isDirectory,
className = 'w-4 h-4',
}) => {
if (isDirectory) { if (isDirectory) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg> </svg>
); );
} }
@@ -18,12 +28,20 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
const ext = fileName.split('.').pop()?.toLowerCase(); const ext = fileName.split('.').pop()?.toLowerCase();
// Image files // Image files
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext || '')) { if (
['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext || '')
) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/> className={className}
<circle cx="8.5" cy="8.5" r="1.5"/> viewBox="0 0 24 24"
<polyline points="21,15 16,10 5,21"/> fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21,15 16,10 5,21" />
</svg> </svg>
); );
} }
@@ -31,9 +49,15 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Video files // Video files
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) { if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<polygon points="23 7 16 12 23 17 23 7"/> className={className}
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg> </svg>
); );
} }
@@ -41,10 +65,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Audio files // Audio files
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) { if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M9 18V5l12-2v13"/> className={className}
<circle cx="6" cy="18" r="3"/> viewBox="0 0 24 24"
<circle cx="18" cy="16" r="3"/> fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg> </svg>
); );
} }
@@ -52,12 +82,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Archive/compressed files // Archive/compressed files
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) { if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M16 22h2a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v3"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z"/> fill="none"
<path d="M10 7h4"/> stroke="currentColor"
<path d="M10 11h4"/> strokeWidth="2"
>
<path d="M16 22h2a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v3" />
<polyline points="14,2 14,8 20,8" />
<path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z" />
<path d="M10 7h4" />
<path d="M10 11h4" />
</svg> </svg>
); );
} }
@@ -65,12 +101,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// PDF files // PDF files
if (ext === 'pdf') { if (ext === 'pdf') {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<path d="M10 12h4"/> fill="none"
<path d="M10 16h2"/> stroke="currentColor"
<path d="M10 8h2"/> strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<path d="M10 12h4" />
<path d="M10 16h2" />
<path d="M10 8h2" />
</svg> </svg>
); );
} }
@@ -78,10 +120,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Design files // Design files
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) { if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/> className={className}
<path d="M9 9h6v6h-6z"/> viewBox="0 0 24 24"
<path d="M16 3.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3z"/> fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<path d="M9 9h6v6h-6z" />
<path d="M16 3.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3z" />
</svg> </svg>
); );
} }
@@ -89,10 +137,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// JavaScript/TypeScript files // JavaScript/TypeScript files
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) { if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<path d="M10 13l2 2 4-4"/> fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<path d="M10 13l2 2 4-4" />
</svg> </svg>
); );
} }
@@ -100,12 +154,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Python files // Python files
if (['py', 'pyw', 'pyc'].includes(ext || '')) { if (['py', 'pyw', 'pyc'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<circle cx="10" cy="12" r="2"/> fill="none"
<circle cx="14" cy="16" r="2"/> stroke="currentColor"
<path d="M12 10c0-1 1-2 2-2s2 1 2 2-1 2-2 2"/> strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<circle cx="10" cy="12" r="2" />
<circle cx="14" cy="16" r="2" />
<path d="M12 10c0-1 1-2 2-2s2 1 2 2-1 2-2 2" />
</svg> </svg>
); );
} }
@@ -113,11 +173,17 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// HTML files // HTML files
if (['html', 'htm', 'xhtml'].includes(ext || '')) { if (['html', 'htm', 'xhtml'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<polyline points="9,13 9,17 15,17 15,13"/> fill="none"
<line x1="12" y1="13" x2="12" y2="17"/> stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<polyline points="9,13 9,17 15,17 15,13" />
<line x1="12" y1="13" x2="12" y2="17" />
</svg> </svg>
); );
} }
@@ -125,12 +191,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// CSS files // CSS files
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) { if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<path d="M8 13h8"/> fill="none"
<path d="M8 17h8"/> stroke="currentColor"
<circle cx="12" cy="15" r="1"/> strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<path d="M8 13h8" />
<path d="M8 17h8" />
<circle cx="12" cy="15" r="1" />
</svg> </svg>
); );
} }
@@ -138,11 +210,17 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// JSON/Data files // JSON/Data files
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) { if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<path d="M9 13v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"/> fill="none"
<path d="M9 17v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"/> stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<path d="M9 13v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
<path d="M9 17v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
</svg> </svg>
); );
} }
@@ -150,12 +228,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Markdown files // Markdown files
if (['md', 'markdown', 'mdx'].includes(ext || '')) { if (['md', 'markdown', 'mdx'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<line x1="16" y1="13" x2="8" y2="13"/> fill="none"
<line x1="16" y1="17" x2="8" y2="17"/> stroke="currentColor"
<polyline points="10,9 9,9 8,9"/> strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg> </svg>
); );
} }
@@ -163,35 +247,68 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Database files // Database files
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) { if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<ellipse cx="12" cy="5" rx="9" ry="3"/> className={className}
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/> viewBox="0 0 24 24"
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/> fill="none"
stroke="currentColor"
strokeWidth="2"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
</svg> </svg>
); );
} }
// Configuration files // Configuration files
if (['env', 'ini', 'cfg', 'conf', 'config', 'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc'].includes(ext || '') || if (
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())) { [
'env',
'ini',
'cfg',
'conf',
'config',
'gitignore',
'dockerignore',
'editorconfig',
'prettierrc',
'eslintrc',
].includes(ext || '') ||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())
) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<circle cx="12" cy="12" r="3"/> className={className}
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg> </svg>
); );
} }
// Text files // Text files
if (['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') || if (
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())) { ['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())
) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<line x1="16" y1="13" x2="8" y2="13"/> fill="none"
<line x1="16" y1="17" x2="8" y2="17"/> stroke="currentColor"
<polyline points="10,9 9,9 8,9"/> strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg> </svg>
); );
} }
@@ -199,10 +316,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Executable files // Executable files
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) { if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<polygon points="14 2 18 6 18 20 6 20 6 4 14 4"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<path d="M10 12l2 2 4-4"/> fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polygon points="14 2 18 6 18 20 6 20 6 4 14 4" />
<polyline points="14,2 14,8 20,8" />
<path d="M10 12l2 2 4-4" />
</svg> </svg>
); );
} }
@@ -210,21 +333,33 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
// Script files // Script files
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) { if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) {
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<polyline points="4 17 10 11 4 5"/> className={className}
<line x1="12" y1="19" x2="20" y2="19"/> viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg> </svg>
); );
} }
// Default file icon // Default file icon
return ( return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> className={className}
<polyline points="14,2 14,8 20,8"/> viewBox="0 0 24 24"
<line x1="16" y1="13" x2="8" y2="13"/> fill="none"
<line x1="16" y1="17" x2="8" y2="17"/> stroke="currentColor"
<polyline points="10,9 9,9 8,9"/> strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10,9 9,9 8,9" />
</svg> </svg>
); );
}; };

View File

@@ -1,4 +1,5 @@
import { Goose, Rain } from './icons/Goose'; import { Goose, Rain } from './icons/Goose';
import { cn } from '../utils';
interface GooseLogoProps { interface GooseLogoProps {
className?: string; className?: string;
@@ -28,12 +29,21 @@ export default function GooseLogo({
return ( return (
<div <div
className={`${className} ${currentSize.frame} ${hover ? 'group/with-hover' : ''} relative overflow-hidden`} className={cn(
className,
currentSize.frame,
'relative overflow-hidden',
hover && 'group/with-hover'
)}
> >
<Rain <Rain
className={`${currentSize.rain} absolute left-0 bottom-0 ${hover ? 'opacity-0 group-hover/with-hover:opacity-100' : ''} transition-all duration-300 z-1`} className={cn(
currentSize.rain,
'absolute left-0 bottom-0 transition-all duration-300 z-1',
hover && 'opacity-0 group-hover/with-hover:opacity-100'
)}
/> />
<Goose className={`${currentSize.goose} absolute left-0 bottom-0 z-2`} /> <Goose className={cn(currentSize.goose, 'absolute left-0 bottom-0 z-2')} />
</div> </div>
); );
} }

View File

@@ -29,6 +29,7 @@ interface GooseMessageProps {
toolCallNotifications: Map<string, NotificationEvent[]>; toolCallNotifications: Map<string, NotificationEvent[]>;
append: (value: string) => void; append: (value: string) => void;
appendMessage: (message: Message) => void; appendMessage: (message: Message) => void;
isStreaming?: boolean; // Whether this message is currently being streamed
} }
export default function GooseMessage({ export default function GooseMessage({
@@ -39,8 +40,11 @@ export default function GooseMessage({
toolCallNotifications, toolCallNotifications,
append, append,
appendMessage, appendMessage,
isStreaming = false,
}: GooseMessageProps) { }: GooseMessageProps) {
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
// Track which tool confirmations we've already handled to prevent infinite loops
const handledToolConfirmations = useRef<Set<string>>(new Set());
// Extract text content from the message // Extract text content from the message
let textContent = getTextContent(message); let textContent = getTextContent(message);
@@ -115,17 +119,29 @@ export default function GooseMessage({
if ( if (
messageIndex === messageHistoryIndex - 1 && messageIndex === messageHistoryIndex - 1 &&
hasToolConfirmation && hasToolConfirmation &&
toolConfirmationContent toolConfirmationContent &&
!handledToolConfirmations.current.has(toolConfirmationContent.id)
) { ) {
// Only append the error message if there isn't already a response for this tool confirmation
const hasExistingResponse = messages.some((msg) =>
getToolResponses(msg).some((response) => response.id === toolConfirmationContent.id)
);
if (!hasExistingResponse) {
// Mark this tool confirmation as handled to prevent infinite loop
handledToolConfirmations.current.add(toolConfirmationContent.id);
appendMessage( appendMessage(
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.') createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
); );
} }
}
}, [ }, [
messageIndex, messageIndex,
messageHistoryIndex, messageHistoryIndex,
hasToolConfirmation, hasToolConfirmation,
toolConfirmationContent, toolConfirmationContent,
messages,
appendMessage, appendMessage,
]); ]);
@@ -147,7 +163,7 @@ export default function GooseMessage({
{/* Visible assistant response */} {/* Visible assistant response */}
{displayText && ( {displayText && (
<div className="flex flex-col group"> <div className="flex flex-col group">
<div className={`goose-message-content pt-2`}> <div className={`goose-message-content py-2`}>
<div ref={contentRef}>{<MarkdownContent content={displayText} />}</div> <div ref={contentRef}>{<MarkdownContent content={displayText} />}</div>
</div> </div>
@@ -160,14 +176,16 @@ export default function GooseMessage({
</div> </div>
)} )}
{/* Only show MessageCopyLink if there's text content and no tool requests/responses */} {/* Only show timestamp and copy link when not streaming */}
<div className="relative flex justify-start"> <div className="relative flex justify-start">
{toolRequests.length === 0 && ( {toolRequests.length === 0 && !isStreaming && (
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0"> <div className="text-xs font-mono text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp} {timestamp}
</div> </div>
)} )}
{displayText && message.content.every((content) => content.type === 'text') && ( {displayText &&
message.content.every((content) => content.type === 'text') &&
!isStreaming && (
<div className="absolute left-0 pt-1"> <div className="absolute left-0 pt-1">
<MessageCopyLink text={displayText} contentRef={contentRef} /> <MessageCopyLink text={displayText} contentRef={contentRef} />
</div> </div>
@@ -179,10 +197,7 @@ export default function GooseMessage({
{toolRequests.length > 0 && ( {toolRequests.length > 0 && (
<div className="relative flex flex-col w-full"> <div className="relative flex flex-col w-full">
{toolRequests.map((toolRequest) => ( {toolRequests.map((toolRequest) => (
<div <div className={`goose-message-tool pb-2`} key={toolRequest.id}>
className={`goose-message-tool bg-bgSubtle rounded px-2 py-2 mb-2`}
key={toolRequest.id}
>
<ToolCallWithResponse <ToolCallWithResponse
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled. // If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
isCancelledMessage={ isCancelledMessage={
@@ -192,11 +207,12 @@ export default function GooseMessage({
toolRequest={toolRequest} toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)} toolResponse={toolResponsesMap.get(toolRequest.id)}
notifications={toolCallNotifications.get(toolRequest.id)} notifications={toolCallNotifications.get(toolRequest.id)}
isStreamingMessage={isStreaming}
/> />
</div> </div>
))} ))}
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0"> <div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp} {!isStreaming && timestamp}
</div> </div>
</div> </div>
)} )}

View File

@@ -147,11 +147,7 @@ export default function GooseResponseForm({
<div className="space-y-4"> <div className="space-y-4">
{isQuestion && !isOptions && !isForm(dynamicForm) && ( {isQuestion && !isOptions && !isForm(dynamicForm) && (
<div className="flex items-center gap-4 p-4 rounded-lg bg-tool-card dark:bg-tool-card-dark border dark:border-dark-border"> <div className="flex items-center gap-4 p-4 rounded-lg bg-tool-card dark:bg-tool-card-dark border dark:border-dark-border">
<Button <Button onClick={handleAccept} className="w-full sm:w-auto">
onClick={handleAccept}
variant="default"
className="w-full sm:w-auto dark:bg-button-dark"
>
<Send className="h-[14px] w-[14px]" /> <Send className="h-[14px] w-[14px]" />
Take flight with this plan Take flight with this plan
</Button> </Button>
@@ -230,11 +226,7 @@ export default function GooseResponseForm({
</div> </div>
))} ))}
<Button <Button type="submit" className="w-full sm:w-auto mt-4">
type="submit"
variant="default"
className="w-full sm:w-auto mt-4 dark:bg-button-dark"
>
<Send className="h-[14px] w-[14px]" /> <Send className="h-[14px] w-[14px]" />
Submit Form Submit Form
</Button> </Button>

View File

@@ -0,0 +1,196 @@
import React, { useEffect } from 'react';
import { FileText, Clock, Home, Puzzle, History } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import {
SidebarContent,
SidebarFooter,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarGroup,
SidebarGroupContent,
SidebarSeparator,
} from '../ui/sidebar';
import { ChatSmart, Gear } from '../icons';
import { ViewOptions, View } from '../../App';
interface SidebarProps {
onSelectSession: (sessionId: string) => void;
refreshTrigger?: number;
children?: React.ReactNode;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
setView?: (view: View, viewOptions?: ViewOptions) => void;
currentPath?: string;
}
// Main Sidebar Component
const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
const navigate = useNavigate();
useEffect(() => {
// Trigger animation after a small delay
const timer = setTimeout(() => {
// setIsVisible(true);
}, 100);
// eslint-disable-next-line no-undef
return () => clearTimeout(timer);
}, []);
// Helper function to check if a path is active
const isActivePath = (path: string) => {
return currentPath === path;
};
return (
<>
<SidebarContent className="pt-16">
{/* Menu */}
<SidebarMenu>
{/* Navigation Group */}
<SidebarGroup>
<SidebarGroupContent className="space-y-1">
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => {
navigate('/');
}}
isActive={isActivePath('/')}
tooltip="Go back to the main chat screen"
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
>
<Home className="w-4 h-4" />
<span>Home</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* Chat & Configuration Group */}
<SidebarGroup>
<SidebarGroupContent className="space-y-1">
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => navigate('/pair')}
isActive={isActivePath('/pair')}
tooltip="Start pairing with Goose"
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
>
<ChatSmart className="w-4 h-4" />
<span>Chat</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => navigate('/sessions')}
isActive={isActivePath('/sessions')}
tooltip="View your session history"
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
>
<History className="w-4 h-4" />
<span>History</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* Content Group */}
<SidebarGroup>
<SidebarGroupContent className="space-y-1">
{/*<div className="sidebar-item">*/}
{/* <SidebarMenuItem>*/}
{/* <SidebarMenuButton*/}
{/* onClick={() => navigate('/projects')}*/}
{/* isActive={isActivePath('/projects')}*/}
{/* tooltip="Manage your projects"*/}
{/* className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"*/}
{/* >*/}
{/* <FolderKanban className="w-4 h-4" />*/}
{/* <span>Projects</span>*/}
{/* </SidebarMenuButton>*/}
{/* </SidebarMenuItem>*/}
{/*</div>*/}
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => navigate('/recipes')}
isActive={isActivePath('/recipes')}
tooltip="Browse your saved recipes"
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
>
<FileText className="w-4 h-4" />
<span>Recipes</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => navigate('/schedules')}
isActive={isActivePath('/schedules')}
tooltip="Manage scheduled runs"
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
>
<Clock className="w-4 h-4" />
<span>Scheduler</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => navigate('/extensions')}
isActive={isActivePath('/extensions')}
tooltip="Manage your extensions"
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
>
<Puzzle className="w-4 h-4" />
<span>Extensions</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* Settings Group */}
<SidebarGroup>
<SidebarGroupContent className="space-y-1">
<div className="sidebar-item">
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => navigate('/settings')}
isActive={isActivePath('/settings')}
tooltip="Configure Goose settings"
className="w-full justify-start px-3 rounded-lg h-fit hover:bg-background-medium/50 transition-all duration-200 data-[active=true]:bg-background-medium"
>
<Gear className="w-4 h-4" />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarGroupContent>
</SidebarGroup>
</SidebarMenu>
</SidebarContent>
<SidebarFooter />
</>
);
};
export default AppSidebar;

View File

@@ -0,0 +1,356 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Search, ChevronDown, Folder, Loader2 } from 'lucide-react';
import { fetchSessions, type Session } from '../../sessions';
import { Input } from '../ui/input';
import {
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
} from '../ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
import { useTextAnimator } from '../../hooks/use-text-animator';
interface SessionsSectionProps {
onSelectSession: (sessionId: string) => void;
refreshTrigger?: number;
}
interface GroupedSessions {
today: Session[];
yesterday: Session[];
older: { [key: string]: Session[] };
}
export const SessionsSection: React.FC<SessionsSectionProps> = ({
onSelectSession,
refreshTrigger,
}) => {
const [sessions, setSessions] = useState<Session[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [groupedSessions, setGroupedSessions] = useState<GroupedSessions>({
today: [],
yesterday: [],
older: {},
});
const [sessionsWithDescriptions, setSessionsWithDescriptions] = useState<Set<string>>(new Set());
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const groupSessions = useCallback((sessionsToGroup: Session[]) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const grouped: GroupedSessions = {
today: [],
yesterday: [],
older: {},
};
sessionsToGroup.forEach((session) => {
const sessionDate = new Date(session.modified);
const sessionDateOnly = new Date(
sessionDate.getFullYear(),
sessionDate.getMonth(),
sessionDate.getDate()
);
if (sessionDateOnly.getTime() === today.getTime()) {
grouped.today.push(session);
} else if (sessionDateOnly.getTime() === yesterday.getTime()) {
grouped.yesterday.push(session);
} else {
const dateKey = sessionDateOnly.toISOString().split('T')[0];
if (!grouped.older[dateKey]) {
grouped.older[dateKey] = [];
}
grouped.older[dateKey].push(session);
}
});
// Sort older sessions by date (newest first)
const sortedOlder: { [key: string]: Session[] } = {};
Object.keys(grouped.older)
.sort()
.reverse()
.forEach((key) => {
sortedOlder[key] = grouped.older[key];
});
grouped.older = sortedOlder;
setGroupedSessions(grouped);
}, []);
const loadSessions = useCallback(async () => {
try {
const sessions = await fetchSessions();
setSessions(sessions);
groupSessions(sessions);
} catch (err) {
console.error('Failed to load sessions:', err);
setSessions([]);
setGroupedSessions({ today: [], yesterday: [], older: {} });
}
}, [groupSessions]);
// Debounced refresh function
const debouncedRefresh = useCallback(() => {
console.log('SessionsSection: Debounced refresh triggered');
// Clear any existing timeout
if (refreshTimeoutRef.current) {
window.clearTimeout(refreshTimeoutRef.current);
}
// Set new timeout - reduced to 200ms for faster response
refreshTimeoutRef.current = setTimeout(() => {
console.log('SessionsSection: Executing debounced refresh');
loadSessions();
refreshTimeoutRef.current = null;
}, 200);
}, [loadSessions]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (refreshTimeoutRef.current) {
window.clearTimeout(refreshTimeoutRef.current);
}
};
}, []);
useEffect(() => {
console.log('SessionsSection: Initial load');
loadSessions();
}, [loadSessions]);
// Add effect to refresh sessions when refreshTrigger changes
useEffect(() => {
if (refreshTrigger) {
console.log('SessionsSection: Refresh trigger changed, triggering refresh');
debouncedRefresh();
}
}, [refreshTrigger, debouncedRefresh]);
// Add effect to listen for session creation events
useEffect(() => {
const handleSessionCreated = () => {
console.log('SessionsSection: Session created event received');
debouncedRefresh();
};
const handleMessageStreamFinish = () => {
console.log('SessionsSection: Message stream finished event received');
// Always refresh when message stream finishes
debouncedRefresh();
};
// Listen for custom events that indicate a session was created
window.addEventListener('session-created', handleSessionCreated);
// Also listen for message stream finish events
window.addEventListener('message-stream-finished', handleMessageStreamFinish);
return () => {
window.removeEventListener('session-created', handleSessionCreated);
window.removeEventListener('message-stream-finished', handleMessageStreamFinish);
};
}, [debouncedRefresh]);
useEffect(() => {
if (searchTerm) {
const filtered = sessions.filter((session) =>
(session.metadata.description || session.id)
.toLowerCase()
.includes(searchTerm.toLowerCase())
);
groupSessions(filtered);
} else {
groupSessions(sessions);
}
}, [searchTerm, sessions, groupSessions]);
// Component for individual session items with loading and animation states
const SessionItem = ({ session }: { session: Session }) => {
const hasDescription =
session.metadata.description && session.metadata.description.trim() !== '';
const isNewSession = session.id.match(/^\d{8}_\d{6}$/);
const messageCount = session.metadata.message_count || 0;
// Show loading for new sessions with few messages and no description
// Only show loading for sessions created in the last 5 minutes
const sessionDate = new Date(session.modified);
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const isRecentSession = sessionDate > fiveMinutesAgo;
const shouldShowLoading =
!hasDescription && isNewSession && messageCount <= 2 && isRecentSession;
const [isAnimating, setIsAnimating] = useState(false);
// Use text animator only for sessions that need animation
const descriptionRef = useTextAnimator({
text: isAnimating ? session.metadata.description : '',
});
// Track when description becomes available and trigger animation
useEffect(() => {
if (hasDescription && !sessionsWithDescriptions.has(session.id)) {
setSessionsWithDescriptions((prev) => new Set(prev).add(session.id));
// Only animate for new sessions that were showing loading
if (shouldShowLoading) {
setIsAnimating(true);
}
}
}, [hasDescription, session.id, shouldShowLoading]);
const handleClick = () => {
console.log('SessionItem: Clicked on session:', session.id);
onSelectSession(session.id);
};
return (
<SidebarMenuItem key={session.id}>
<SidebarMenuButton
onClick={handleClick}
className="cursor-pointer w-56 transition-all duration-300 ease-in-out hover:bg-background-medium hover:shadow-sm rounded-xl text-text-muted hover:text-text-default h-fit flex items-start transform hover:scale-[1.02] active:scale-[0.98]"
>
<div className="flex flex-col w-full">
<div className="text-sm w-48 truncate mb-1 px-1 text-ellipsis text-text-default flex items-center gap-2">
{shouldShowLoading ? (
<div className="flex items-center gap-2 animate-in fade-in duration-300">
<Loader2 className="size-3 animate-spin text-text-default" />
<span className="text-text-default animate-pulse">Generating description...</span>
</div>
) : (
<span
ref={isAnimating ? descriptionRef : undefined}
className={`transition-all duration-300 ${isAnimating ? 'animate-in fade-in duration-300' : ''}`}
>
{hasDescription ? session.metadata.description : `Session ${session.id}`}
</span>
)}
</div>
<div className="text-xs w-48 truncate px-1 flex items-center gap-2 text-ellipsis transition-colors duration-300">
<Folder className="size-4 transition-transform duration-300 group-hover:scale-110" />
<span className="transition-all duration-300">{session.metadata.working_dir}</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
};
const renderSessionGroup = (sessions: Session[], title: string, index: number) => {
if (sessions.length === 0) return null;
const isFirstTwoGroups = index < 2;
return (
<Collapsible defaultOpen={isFirstTwoGroups} className="group/collapsible">
<SidebarGroup>
<CollapsibleTrigger className="w-full">
<SidebarGroupLabel className="flex cursor-pointer items-center justify-between text-text-default hover:text-text-default h-12 pl-3 transition-all duration-200 rounded-lg">
<div className="flex min-w-0 items-center">
<span className="opacity-100 transition-all duration-300 text-xs font-medium">
{title}
</span>
</div>
<ChevronDown className="size-4 text-text-muted flex-shrink-0 opacity-100 transition-all duration-300 ease-in-out group-data-[state=open]/collapsible:rotate-180" />
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up overflow-hidden transition-all duration-300 ease-in-out">
<SidebarGroupContent>
<SidebarMenu className="mb-2 space-y-1">
{sessions.map((session, sessionIndex) => (
<div
key={session.id}
className="animate-in slide-in-from-left-2 fade-in duration-300"
style={{
animationDelay: `${sessionIndex * 50}ms`,
animationFillMode: 'both',
}}
>
<SessionItem session={session} />
</div>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
);
};
return (
<Collapsible defaultOpen={false} className="group/collapsible rounded-xl">
<SidebarGroup className="px-1">
<CollapsibleTrigger className="w-full">
<SidebarGroupLabel className="flex cursor-pointer items-center py-6 justify-between text-text-default px-4 transition-all duration-200 hover:bg-background-default rounded-lg">
<div className="flex min-w-0 items-center">
<span className="text-sm">Sessions</span>
</div>
<ChevronDown className="size-4 text-text-muted flex-shrink-0 opacity-100 transition-all duration-300 ease-in-out group-data-[state=open]/collapsible:rotate-180" />
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up overflow-hidden transition-all duration-300 ease-in-out">
<SidebarGroupContent>
{/* Search Input */}
<div className="p-1 pb-2 animate-in slide-in-from-top-2 fade-in duration-300">
<div className="relative flex flex-row items-center gap-2">
<Search className="absolute top-2.5 left-2.5 size-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search sessions..."
className="pl-8 transition-all duration-200 focus:ring-2 focus:ring-borderProminent"
value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
/>
</div>
</div>
{/* Sessions Groups */}
<div className="space-y-2">
{(() => {
let groupIndex = 0;
const groups = [
{ sessions: groupedSessions.today, title: 'Today' },
{ sessions: groupedSessions.yesterday, title: 'Yesterday' },
...Object.entries(groupedSessions.older).map(([date, sessions]) => ({
sessions,
title: new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})),
];
return groups.map(({ sessions, title }) => {
if (sessions.length === 0) return null;
const currentIndex = groupIndex++;
return (
<div
key={title}
className="animate-in slide-in-from-left-2 fade-in duration-300"
style={{
animationDelay: `${currentIndex * 100}ms`,
animationFillMode: 'both',
}}
>
{renderSessionGroup(sessions, title, currentIndex)}
</div>
);
});
})()}
</div>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
);
};

View File

@@ -0,0 +1,135 @@
import React, { useEffect, useState } from 'react';
import { Moon, Sliders, Sun } from 'lucide-react';
import { Button } from '../ui/button';
interface ThemeSelectorProps {
className?: string;
hideTitle?: boolean;
horizontal?: boolean;
}
const ThemeSelector: React.FC<ThemeSelectorProps> = ({
className = '',
hideTitle = false,
horizontal = false,
}) => {
const [themeMode, setThemeMode] = useState<'light' | 'dark' | 'system'>(() => {
const savedUseSystemTheme = localStorage.getItem('use_system_theme') === 'true';
if (savedUseSystemTheme) {
return 'system';
}
const savedTheme = localStorage.getItem('theme');
return savedTheme === 'dark' ? 'dark' : 'light';
});
const [isDarkMode, setDarkMode] = useState(() => {
// First check localStorage to determine the intended theme
const savedUseSystemTheme = localStorage.getItem('use_system_theme') === 'true';
const savedTheme = localStorage.getItem('theme');
if (savedUseSystemTheme) {
// Use system preference
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return systemPrefersDark;
} else if (savedTheme) {
// Use saved theme preference
return savedTheme === 'dark';
} else {
// Fallback: check current DOM state to maintain consistency
return document.documentElement.classList.contains('dark');
}
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleThemeChange = (e: { matches: boolean }) => {
if (themeMode === 'system') {
setDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handleThemeChange);
if (themeMode === 'system') {
setDarkMode(mediaQuery.matches);
localStorage.setItem('use_system_theme', 'true');
} else {
setDarkMode(themeMode === 'dark');
localStorage.setItem('use_system_theme', 'false');
localStorage.setItem('theme', themeMode);
}
return () => mediaQuery.removeEventListener('change', handleThemeChange);
}, [themeMode]);
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
} else {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
}
}, [isDarkMode]);
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
setThemeMode(newTheme);
};
return (
<div className={`${!horizontal ? 'px-1 py-2 space-y-2' : ''} ${className}`}>
{!hideTitle && <div className="text-xs text-text-default px-3">Theme</div>}
<div
className={`${horizontal ? 'flex' : 'grid grid-cols-3'} gap-1 ${!horizontal ? 'px-3' : ''}`}
>
<Button
data-testid="light-mode-button"
onClick={() => handleThemeChange('light')}
className={`flex items-center justify-center gap-1 p-2 rounded-md border transition-colors text-xs ${
themeMode === 'light'
? 'bg-background-accent text-text-on-accent border-border-accent hover:!bg-background-accent hover:!text-text-on-accent'
: 'border-border-default hover:!bg-background-muted text-text-muted hover:text-text-default'
}`}
variant="ghost"
size="sm"
>
<Sun className="h-3 w-3" />
<span>Light</span>
</Button>
<Button
data-testid="dark-mode-button"
onClick={() => handleThemeChange('dark')}
className={`flex items-center justify-center gap-1 p-2 rounded-md border transition-colors text-xs ${
themeMode === 'dark'
? 'bg-background-accent text-text-on-accent border-border-accent hover:!bg-background-accent hover:!text-text-on-accent'
: 'border-border-default hover:!bg-background-muted text-text-muted hover:text-text-default'
}`}
variant="ghost"
size="sm"
>
<Moon className="h-3 w-3" />
<span>Dark</span>
</Button>
<Button
data-testid="system-mode-button"
onClick={() => handleThemeChange('system')}
className={`flex items-center justify-center gap-1 p-2 rounded-md border transition-colors text-xs ${
themeMode === 'system'
? 'bg-background-accent text-text-on-accent border-border-accent hover:!bg-background-accent hover:!text-text-on-accent'
: 'border-border-default hover:!bg-background-muted text-text-muted hover:text-text-default'
}`}
variant="ghost"
size="sm"
>
<Sliders className="h-3 w-3" />
<span>System</span>
</Button>
</div>
</div>
);
};
export default ThemeSelector;

View File

@@ -0,0 +1 @@
export { default as AppSidebar } from './AppSidebar';

View File

@@ -1,25 +1,14 @@
import React, { useEffect, useState } from 'react'; import { useState, useEffect } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Check } from './icons'; import { Check } from './icons';
import {
const Modal = ({ children }: { children: React.ReactNode }) => ( Dialog,
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] z-[1000]"> DialogContent,
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col min-w-[80%] min-h-[80%] bg-bgApp rounded-xl overflow-hidden shadow-none px-8 pt-[24px] pb-0"> DialogDescription,
<div className="flex flex-col flex-1 space-y-8 text-base text-textStandard h-full"> DialogFooter,
{children} DialogHeader,
</div> DialogTitle,
</Card> } from './ui/dialog';
</div>
);
const ModalHeader = () => (
<div className="space-y-8">
<div className="flex">
<h2 className="text-2xl font-regular text-textStandard">Configure .goosehints</h2>
</div>
</div>
);
const ModalHelpText = () => ( const ModalHelpText = () => (
<div className="text-sm flex-col space-y-4"> <div className="text-sm flex-col space-y-4">
@@ -66,27 +55,6 @@ const ModalFileInfo = ({ filePath, found }: { filePath: string; found: boolean }
</div> </div>
); );
const ModalButtons = ({ onSubmit, onCancel }: { onSubmit: () => void; onCancel: () => void }) => (
<div className="-ml-8 -mr-8">
<Button
type="submit"
variant="ghost"
onClick={onSubmit}
className="w-full h-[60px] rounded-none border-t border-borderSubtle text-base hover:bg-bgSubtle text-textProminent font-regular"
>
Save
</Button>
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="w-full h-[60px] rounded-none border-t border-borderSubtle hover:text-textStandard text-textSubtle hover:bg-bgSubtle text-base font-regular"
>
Cancel
</Button>
</div>
);
const getGoosehintsFile = async (filePath: string) => await window.electron.readFile(filePath); const getGoosehintsFile = async (filePath: string) => await window.electron.readFile(filePath);
type GoosehintsModalProps = { type GoosehintsModalProps = {
@@ -122,26 +90,45 @@ export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: Goosehi
setIsGoosehintsModalOpen(false); setIsGoosehintsModalOpen(false);
}; };
const handleClose = () => {
setIsGoosehintsModalOpen(false);
};
return ( return (
<Modal> <Dialog open={true} onOpenChange={handleClose}>
<ModalHeader /> <DialogContent className="sm:max-w-[80%] sm:max-h-[80%] overflow-auto">
<DialogHeader>
<DialogTitle>Configure .goosehints</DialogTitle>
<DialogDescription>
Configure your project's .goosehints file to provide additional context to Goose.
</DialogDescription>
</DialogHeader>
<ModalHelpText /> <ModalHelpText />
<div className="flex flex-col flex-1">
<div className="py-4">
{goosehintsFileReadError ? ( {goosehintsFileReadError ? (
<ModalError error={new Error(goosehintsFileReadError)} /> <ModalError error={new Error(goosehintsFileReadError)} />
) : ( ) : (
<div className="flex flex-col flex-1 space-y-2 h-full"> <div className="space-y-2">
<ModalFileInfo filePath={goosehintsFilePath} found={goosehintsFileFound} /> <ModalFileInfo filePath={goosehintsFilePath} found={goosehintsFileFound} />
<textarea <textarea
defaultValue={goosehintsFile} defaultValue={goosehintsFile}
autoFocus autoFocus
className="w-full flex-1 border rounded-md min-h-20 p-2 text-sm resize-none bg-bgApp text-textStandard border-borderStandard focus:outline-none" className="w-full h-80 border rounded-md p-2 text-sm resize-none bg-background-default text-textStandard border-borderStandard focus:outline-none"
onChange={(event) => setGoosehintsFile(event.target.value)} onChange={(event) => setGoosehintsFile(event.target.value)}
/> />
</div> </div>
)} )}
</div> </div>
<ModalButtons onSubmit={writeFile} onCancel={() => setIsGoosehintsModalOpen(false)} />
</Modal> <DialogFooter className="pt-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={writeFile}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };

View File

@@ -13,9 +13,9 @@ export default function LayingEggLoader() {
}, []); }, []);
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 bg-bgApp"> <div className="fixed inset-0 flex items-center justify-center z-50 bg-background-default">
<div className="flex flex-col items-center max-w-3xl w-full px-6 pt-10"> <div className="flex flex-col items-center max-w-3xl w-full px-6 pt-10">
<div className="w-16 h-16 bg-bgApp rounded-full flex items-center justify-center mb-4"> <div className="w-16 h-16 bg-background-default rounded-full flex items-center justify-center mb-4">
<Geese className="w-12 h-12 text-iconProminent" /> <Geese className="w-12 h-12 text-iconProminent" />
</div> </div>
<h1 className="text-2xl font-medium text-center mb-2 text-textProminent"> <h1 className="text-2xl font-medium text-center mb-2 text-textProminent">

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import AppSidebar from '../GooseSidebar/AppSidebar';
import { View, ViewOptions } from '../../App';
import { AppWindowMac, AppWindow } from 'lucide-react';
import { Button } from '../ui/button';
import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar';
interface AppLayoutProps {
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
}
// Inner component that uses useSidebar within SidebarProvider context
const AppLayoutContent: React.FC<AppLayoutProps> = ({ setIsGoosehintsModalOpen }) => {
const navigate = useNavigate();
const location = useLocation();
const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin';
const { isMobile, openMobile } = useSidebar();
// Calculate padding based on sidebar state and macOS
const headerPadding = safeIsMacOS ? 'pl-21' : 'pl-4';
// const headerPadding = '';
// Hide buttons when mobile sheet is showing
const shouldHideButtons = isMobile && openMobile;
const setView = (view: View, viewOptions?: ViewOptions) => {
// Convert view-based navigation to route-based navigation
switch (view) {
case 'chat':
navigate('/');
break;
case 'pair':
navigate('/pair');
break;
case 'settings':
navigate('/settings', { state: viewOptions });
break;
case 'extensions':
navigate('/extensions', { 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('/');
}
};
const handleSelectSession = async (sessionId: string) => {
// Navigate to chat with session data
navigate('/', { state: { sessionId } });
};
const handleNewWindow = () => {
window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined
);
};
return (
<div className="flex flex-1 w-full relative animate-fade-in">
{!shouldHideButtons && (
<div className={`${headerPadding} absolute top-3 z-100 flex items-center`}>
<SidebarTrigger
className={`no-drag hover:border-border-strong hover:text-text-default hover:!bg-background-medium hover:scale-105`}
/>
<Button
onClick={handleNewWindow}
className="no-drag hover:!bg-background-medium"
variant="ghost"
size="xs"
title="Start a new session in a new window"
>
{safeIsMacOS ? <AppWindowMac className="w-4 h-4" /> : <AppWindow className="w-4 h-4" />}
</Button>
</div>
)}
<Sidebar variant="inset" collapsible="offcanvas">
<AppSidebar
onSelectSession={handleSelectSession}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
currentPath={location.pathname}
/>
</Sidebar>
<SidebarInset>
<Outlet />
</SidebarInset>
</div>
);
};
export const AppLayout: React.FC<AppLayoutProps> = ({ setIsGoosehintsModalOpen }) => {
return (
<SidebarProvider>
<AppLayoutContent setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} />
</SidebarProvider>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
export const MainPanelLayout: React.FC<{
children: React.ReactNode;
removeTopPadding?: boolean;
backgroundColor?: string;
}> = ({ children, removeTopPadding = false, backgroundColor = 'bg-background-default' }) => {
return (
<div className={`h-dvh`}>
{/* Padding top matches the app toolbar drag area height - can be removed for full bleed */}
<div
className={`flex flex-col ${backgroundColor} flex-1 min-w-0 h-full min-h-0 ${removeTopPadding ? '' : 'pt-[32px]'}`}
>
{children}
</div>
</div>
);
};

View File

@@ -1,14 +1,18 @@
import GooseLogo from './GooseLogo'; import GooseLogo from './GooseLogo';
const LoadingGoose = () => { interface LoadingGooseProps {
message?: string;
}
const LoadingGoose = ({ message = 'goose is working on it…' }: LoadingGooseProps) => {
return ( return (
<div className="w-full pb-[2px]"> <div className="w-full animate-fade-slide-up">
<div <div
data-testid="loading-indicator" data-testid="loading-indicator"
className="flex items-center text-xs text-textStandard mb-2 mt-2 animate-[appear_250ms_ease-in_forwards]" className="flex items-center gap-2 text-xs text-textStandard py-2"
> >
<GooseLogo className="mr-2" size="small" hover={false} /> <GooseLogo size="small" hover={false} />
goose is working on it {message}
</div> </div>
</div> </div>
); );

View File

@@ -112,17 +112,17 @@ export default function MarkdownContent({ content, className = '' }: MarkdownCon
<div className="w-full overflow-x-hidden"> <div className="w-full overflow-x-hidden">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
className={`prose prose-sm text-textStandard dark:prose-invert w-full max-w-full word-break className={`prose prose-sm text-text-default dark:prose-invert w-full max-w-full word-break
prose-pre:p-0 prose-pre:m-0 !p-0 prose-pre:p-0 prose-pre:m-0 !p-0
prose-code:break-all prose-code:whitespace-pre-wrap prose-code:break-all prose-code:whitespace-pre-wrap
prose-table:table prose-table:w-full prose-table:table prose-table:w-full
prose-blockquote:text-inherit prose-blockquote:text-inherit
prose-td:border prose-td:border-borderSubtle prose-td:p-2 prose-td:border prose-td:border-border-default prose-td:p-2
prose-th:border prose-th:border-borderSubtle prose-th:p-2 prose-th:border prose-th:border-border-default prose-th:p-2
prose-thead:bg-bgSubtle prose-thead:bg-background-default
prose-h1:text-2xl prose-h1:font-medium prose-h1:mb-5 prose-h1:mt-5 prose-h1:text-2xl prose-h1:font-normal prose-h1:mb-5 prose-h1:mt-0
prose-h2:text-xl prose-h2:font-medium prose-h2:mb-4 prose-h2:mt-4 prose-h2:text-xl prose-h2:font-normal prose-h2:mb-4 prose-h2:mt-4
prose-h3:text-lg prose-h3:font-medium prose-h3:mb-3 prose-h3:mt-3 prose-h3:text-lg prose-h3:font-normal prose-h3:mb-3 prose-h3:mt-3
prose-p:mt-0 prose-p:mb-2 prose-p:mt-0 prose-p:mb-2
prose-ol:my-2 prose-ol:my-2
prose-ul:mt-0 prose-ul:mb-3 prose-ul:mt-0 prose-ul:mb-3

View File

@@ -1,4 +1,12 @@
import { useState, useEffect, useRef, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react'; import {
useState,
useEffect,
useRef,
useMemo,
forwardRef,
useImperativeHandle,
useCallback,
} from 'react';
import { FileIcon } from './FileIcon'; import { FileIcon } from './FileIcon';
interface FileItem { interface FileItem {
@@ -46,7 +54,13 @@ const fuzzyMatch = (pattern: string, text: string): { score: number; matches: nu
score += consecutiveMatches * 3; score += consecutiveMatches * 3;
// Bonus for matches at word boundaries or path separators // Bonus for matches at word boundaries or path separators
if (i === 0 || textLower[i - 1] === '/' || textLower[i - 1] === '_' || textLower[i - 1] === '-' || textLower[i - 1] === '.') { if (
i === 0 ||
textLower[i - 1] === '/' ||
textLower[i - 1] === '_' ||
textLower[i - 1] === '-' ||
textLower[i - 1] === '.'
) {
score += 10; score += 10;
} }
@@ -85,15 +99,7 @@ const fuzzyMatch = (pattern: string, text: string): { score: number; matches: nu
const MentionPopover = forwardRef< const MentionPopover = forwardRef<
{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void }, { getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void },
MentionPopoverProps MentionPopoverProps
>(({ >(({ isOpen, onClose, onSelect, position, query, selectedIndex, onSelectedIndexChange }, ref) => {
isOpen,
onClose,
onSelect,
position,
query,
selectedIndex,
onSelectedIndexChange
}, ref) => {
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@@ -102,16 +108,16 @@ const MentionPopover = forwardRef<
// Filter and sort files based on query // Filter and sort files based on query
const displayFiles = useMemo((): FileItemWithMatch[] => { const displayFiles = useMemo((): FileItemWithMatch[] => {
if (!query.trim()) { if (!query.trim()) {
return files.slice(0, 15).map(file => ({ return files.slice(0, 15).map((file) => ({
...file, ...file,
matchScore: 0, matchScore: 0,
matches: [], matches: [],
matchedText: file.name matchedText: file.name,
})); // Show first 15 files when no query })); // Show first 15 files when no query
} }
const results = files const results = files
.map(file => { .map((file) => {
const nameMatch = fuzzyMatch(query, file.name); const nameMatch = fuzzyMatch(query, file.name);
const pathMatch = fuzzyMatch(query, file.relativePath); const pathMatch = fuzzyMatch(query, file.relativePath);
const fullPathMatch = fuzzyMatch(query, file.path); const fullPathMatch = fuzzyMatch(query, file.path);
@@ -134,10 +140,10 @@ const MentionPopover = forwardRef<
...file, ...file,
matchScore: bestMatch.score, matchScore: bestMatch.score,
matches: bestMatch.matches, matches: bestMatch.matches,
matchedText matchedText,
}; };
}) })
.filter(file => file.matchScore > 0) .filter((file) => file.matchScore > 0)
.sort((a, b) => { .sort((a, b) => {
// Sort by score first, then prefer files over directories, then alphabetically // Sort by score first, then prefer files over directories, then alphabetically
if (Math.abs(a.matchScore - b.matchScore) < 1) { if (Math.abs(a.matchScore - b.matchScore) < 1) {
@@ -154,15 +160,19 @@ const MentionPopover = forwardRef<
}, [files, query]); }, [files, query]);
// Expose methods to parent component // Expose methods to parent component
useImperativeHandle(ref, () => ({ useImperativeHandle(
ref,
() => ({
getDisplayFiles: () => displayFiles, getDisplayFiles: () => displayFiles,
selectFile: (index: number) => { selectFile: (index: number) => {
if (displayFiles[index]) { if (displayFiles[index]) {
onSelect(displayFiles[index].path); onSelect(displayFiles[index].path);
onClose(); onClose();
} }
} },
}), [displayFiles, onSelect, onClose]); }),
[displayFiles, onSelect, onClose]
);
// Scan files when component opens // Scan files when component opens
useEffect(() => { useEffect(() => {
@@ -189,7 +199,8 @@ const MentionPopover = forwardRef<
}; };
}, [isOpen, onClose]); }, [isOpen, onClose]);
const scanDirectoryFromRoot = useCallback(async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => { const scanDirectoryFromRoot = useCallback(
async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
// Increase depth limit for better file discovery // Increase depth limit for better file discovery
if (depth > 5) return []; if (depth > 5) return [];
@@ -198,15 +209,40 @@ const MentionPopover = forwardRef<
const results: FileItem[] = []; const results: FileItem[] = [];
// Common directories to prioritize or skip // Common directories to prioritize or skip
const priorityDirs = ['Desktop', 'Documents', 'Downloads', 'Projects', 'Development', 'Code', 'src', 'components', 'icons']; const priorityDirs = [
'Desktop',
'Documents',
'Downloads',
'Projects',
'Development',
'Code',
'src',
'components',
'icons',
];
const skipDirs = [ const skipDirs = [
'.git', '.svn', '.hg', 'node_modules', '__pycache__', '.vscode', '.idea', '.git',
'target', 'dist', 'build', '.cache', '.npm', '.yarn', 'Library', '.svn',
'System', 'Applications', '.Trash' '.hg',
'node_modules',
'__pycache__',
'.vscode',
'.idea',
'target',
'dist',
'build',
'.cache',
'.npm',
'.yarn',
'Library',
'System',
'Applications',
'.Trash',
]; ];
// Don't skip as many directories at deeper levels to find more files // Don't skip as many directories at deeper levels to find more files
const skipDirsAtDepth = depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs; const skipDirsAtDepth =
depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
// Sort items to prioritize certain directories // Sort items to prioritize certain directories
const sortedItems = items.sort((a, b) => { const sortedItems = items.sort((a, b) => {
@@ -234,20 +270,81 @@ const MentionPopover = forwardRef<
const ext = item.split('.').pop()?.toLowerCase(); const ext = item.split('.').pop()?.toLowerCase();
const commonExtensions = [ const commonExtensions = [
// Code files // Code files
'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'h', 'txt',
'css', 'html', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'md',
'sh', 'bat', 'ps1', 'rb', 'go', 'rs', 'php', 'sql', 'r', 'scala', 'js',
'swift', 'kt', 'dart', 'vue', 'svelte', 'astro', 'scss', 'less', 'ts',
'jsx',
'tsx',
'py',
'java',
'cpp',
'c',
'h',
'css',
'html',
'json',
'xml',
'yaml',
'yml',
'toml',
'ini',
'cfg',
'sh',
'bat',
'ps1',
'rb',
'go',
'rs',
'php',
'sql',
'r',
'scala',
'swift',
'kt',
'dart',
'vue',
'svelte',
'astro',
'scss',
'less',
// Documentation // Documentation
'readme', 'license', 'changelog', 'contributing', 'readme',
'license',
'changelog',
'contributing',
// Config files // Config files
'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc', 'gitignore',
'dockerignore',
'editorconfig',
'prettierrc',
'eslintrc',
// Images and assets // Images and assets
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif', 'png',
'jpg',
'jpeg',
'gif',
'svg',
'ico',
'webp',
'bmp',
'tiff',
'tif',
// Vector and design files // Vector and design files
'ai', 'eps', 'sketch', 'fig', 'xd', 'psd', 'ai',
'eps',
'sketch',
'fig',
'xd',
'psd',
// Other common files // Other common files
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx' 'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
]; ];
// If it has a known file extension, treat it as a file // If it has a known file extension, treat it as a file
@@ -256,19 +353,26 @@ const MentionPopover = forwardRef<
path: fullPath, path: fullPath,
name: item, name: item,
isDirectory: false, isDirectory: false,
relativePath: itemRelativePath relativePath: itemRelativePath,
}); });
continue; continue;
} }
// If it's a known file without extension (README, LICENSE, etc.) // If it's a known file without extension (README, LICENSE, etc.)
const knownFiles = ['readme', 'license', 'changelog', 'contributing', 'dockerfile', 'makefile']; const knownFiles = [
'readme',
'license',
'changelog',
'contributing',
'dockerfile',
'makefile',
];
if (!hasExtension && knownFiles.includes(item.toLowerCase())) { if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
results.push({ results.push({
path: fullPath, path: fullPath,
name: item, name: item,
isDirectory: false, isDirectory: false,
relativePath: itemRelativePath relativePath: itemRelativePath,
}); });
continue; continue;
} }
@@ -282,7 +386,7 @@ const MentionPopover = forwardRef<
path: fullPath, path: fullPath,
name: item, name: item,
isDirectory: true, isDirectory: true,
relativePath: itemRelativePath relativePath: itemRelativePath,
}); });
// Recursively scan directories more aggressively // Recursively scan directories more aggressively
@@ -301,7 +405,9 @@ const MentionPopover = forwardRef<
console.error(`Error scanning directory ${dirPath}:`, error); console.error(`Error scanning directory ${dirPath}:`, error);
return []; return [];
} }
}, []); },
[]
);
const scanFilesFromRoot = useCallback(async () => { const scanFilesFromRoot = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -348,7 +454,7 @@ const MentionPopover = forwardRef<
return ( return (
<div <div
ref={popoverRef} ref={popoverRef}
className="fixed z-50 bg-bgApp border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg" className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg"
style={{ style={{
left: position.x, left: position.x,
top: position.y - 10, // Position above the chat input top: position.y - 10, // Position above the chat input
@@ -378,12 +484,8 @@ const MentionPopover = forwardRef<
<FileIcon fileName={file.name} isDirectory={file.isDirectory} /> <FileIcon fileName={file.name} isDirectory={file.isDirectory} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm truncate text-textStandard"> <div className="text-sm truncate text-textStandard">{file.name}</div>
{file.name} <div className="text-xs text-textSubtle truncate">{file.path}</div>
</div>
<div className="text-xs text-textSubtle truncate">
{file.path}
</div>
</div> </div>
</div> </div>
))} ))}

View File

@@ -51,7 +51,7 @@ export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkPro
return ( return (
<button <button
onClick={handleCopy} onClick={handleCopy}
className="flex items-center gap-1 text-xs text-textSubtle hover:cursor-pointer hover:text-textProminent transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0" className="flex font-mono items-center gap-1 text-xs text-textSubtle hover:cursor-pointer hover:text-textProminent transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0"
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
<span>{copied ? 'Copied!' : 'Copy'}</span> <span>{copied ? 'Copied!' : 'Copy'}</span>

View File

@@ -69,11 +69,11 @@ export default function Modal({
> >
<Card <Card
ref={modalRef} ref={modalRef}
className="relative w-[500px] max-w-full bg-bgApp rounded-xl my-10 max-h-[90vh] flex flex-col shadow-xl z-[10000]" 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> <div className="p-8 max-h-[calc(90vh-180px)] overflow-y-auto">{children}</div>
{footer && ( {footer && (
<div className="border-t border-borderSubtle bg-bgApp w-full rounded-b-xl overflow-hidden"> <div className="border-t border-borderSubtle bg-background-default w-full rounded-b-xl overflow-hidden">
{footer} {footer}
</div> </div>
)} )}

View File

@@ -4,6 +4,10 @@ import { toastError, toastSuccess } from '../toasts';
import Model, { getProviderMetadata } from './settings/models/modelInterface'; import Model, { getProviderMetadata } from './settings/models/modelInterface';
import { ProviderMetadata } from '../api'; import { ProviderMetadata } from '../api';
import { useConfig } from './ConfigContext'; import { useConfig } from './ConfigContext';
import {
getModelDisplayName,
getProviderDisplayName,
} from './settings/models/predefinedModelsUtils';
// titles // titles
export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup'; export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup';
@@ -26,6 +30,8 @@ interface ModelAndProviderContextType {
getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>; getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>;
getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>; getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>;
getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>; getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>;
getCurrentModelDisplayName: () => Promise<string>;
getCurrentProviderDisplayName: () => Promise<string>; // Gets provider display name from subtext
refreshCurrentModelAndProvider: () => Promise<void>; refreshCurrentModelAndProvider: () => Promise<void>;
} }
@@ -138,6 +144,30 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
return { model: gooseModel, provider: providerDisplayName }; return { model: gooseModel, provider: providerDisplayName };
}, [getCurrentModelAndProvider, getProviders]); }, [getCurrentModelAndProvider, getProviders]);
const getCurrentModelDisplayName = useCallback(async () => {
try {
const currentModelName = (await read('GOOSE_MODEL', false)) as string;
return getModelDisplayName(currentModelName);
} catch (error) {
return 'Select Model';
}
}, [read]);
const getCurrentProviderDisplayName = useCallback(async () => {
try {
const currentModelName = (await read('GOOSE_MODEL', false)) as string;
const providerDisplayName = getProviderDisplayName(currentModelName);
if (providerDisplayName) {
return providerDisplayName;
}
// Fall back to regular provider display name lookup
const { provider } = await getCurrentModelAndProviderForDisplay();
return provider;
} catch (error) {
return '';
}
}, [read, getCurrentModelAndProviderForDisplay]);
const refreshCurrentModelAndProvider = useCallback(async () => { const refreshCurrentModelAndProvider = useCallback(async () => {
try { try {
const { model, provider } = await getCurrentModelAndProvider(); const { model, provider } = await getCurrentModelAndProvider();
@@ -161,6 +191,8 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
getCurrentModelAndProvider, getCurrentModelAndProvider,
getFallbackModelAndProvider, getFallbackModelAndProvider,
getCurrentModelAndProviderForDisplay, getCurrentModelAndProviderForDisplay,
getCurrentModelDisplayName,
getCurrentProviderDisplayName,
refreshCurrentModelAndProvider, refreshCurrentModelAndProvider,
}), }),
[ [
@@ -170,6 +202,8 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
getCurrentModelAndProvider, getCurrentModelAndProvider,
getFallbackModelAndProvider, getFallbackModelAndProvider,
getCurrentModelAndProviderForDisplay, getCurrentModelAndProviderForDisplay,
getCurrentModelDisplayName,
getCurrentProviderDisplayName,
refreshCurrentModelAndProvider, refreshCurrentModelAndProvider,
] ]
); );

View File

@@ -89,7 +89,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
<div className="fixed inset-0 backdrop-blur-sm z-50 flex justify-center items-center animate-[fadein_200ms_ease-in]"> <div className="fixed inset-0 backdrop-blur-sm z-50 flex justify-center items-center animate-[fadein_200ms_ease-in]">
{showCancelOptions ? ( {showCancelOptions ? (
// Cancel options modal // Cancel options modal
<div className="bg-bgApp border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-md"> <div className="bg-background-default border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-md">
<h2 className="text-xl font-bold text-textProminent mb-4">Cancel Recipe Setup</h2> <h2 className="text-xl font-bold text-textProminent mb-4">Cancel Recipe Setup</h2>
<p className="text-textStandard mb-6">What would you like to do?</p> <p className="text-textStandard mb-6">What would you like to do?</p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -113,7 +113,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
</div> </div>
) : ( ) : (
// Main parameter form // Main parameter form
<div className="bg-bgApp border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-lg"> <div className="bg-background-default border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-lg">
<h2 className="text-xl font-bold text-textProminent mb-6">Recipe Parameters</h2> <h2 className="text-xl font-bold text-textProminent mb-6">Recipe Parameters</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{parameters.map((param) => ( {parameters.map((param) => (

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { FolderTree, MessageSquare, Code } from 'lucide-react';
interface PopularChatTopicsProps {
append: (text: string) => void;
}
interface ChatTopic {
id: string;
icon: React.ReactNode;
description: string;
prompt: string;
}
const POPULAR_TOPICS: ChatTopic[] = [
{
id: 'organize-photos',
icon: <FolderTree className="w-5 h-5" />,
description: 'Organize the photos on my desktop into neat little folders by subject matter',
prompt: 'Organize the photos on my desktop into neat little folders by subject matter',
},
{
id: 'government-forms',
icon: <MessageSquare className="w-5 h-5" />,
description:
'Describe in detail how various forms of government works and rank each by units of geese',
prompt:
'Describe in detail how various forms of government works and rank each by units of geese',
},
{
id: 'tamagotchi-game',
icon: <Code className="w-5 h-5" />,
description:
'Develop a tamagotchi game that lives on my computer and follows a pixelated styling',
prompt: 'Develop a tamagotchi game that lives on my computer and follows a pixelated styling',
},
];
export default function PopularChatTopics({ append }: PopularChatTopicsProps) {
const handleTopicClick = (prompt: string) => {
append(prompt);
};
return (
<div className="absolute bottom-0 left-0 p-6 max-w-md">
<h3 className="text-text-muted text-sm mb-1">Popular chat topics</h3>
<div className="space-y-1">
{POPULAR_TOPICS.map((topic) => (
<div
key={topic.id}
className="flex items-center justify-between py-1.5 hover:bg-bgSubtle rounded-md cursor-pointer transition-colors"
onClick={() => handleTopicClick(topic.prompt)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0 text-text-muted">{topic.icon}</div>
<div className="flex-1 min-w-0">
<p className="text-text-default text-sm leading-tight">{topic.description}</p>
</div>
</div>
<div className="flex-shrink-0 ml-4">
<button
className="text-sm text-text-muted hover:text-text-default transition-colors cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleTopicClick(topic.prompt);
}}
>
Start
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,274 @@
/**
* ProgressiveMessageList Component
*
* A performance-optimized message list that renders messages progressively
* to prevent UI blocking when loading long chat sessions. This component
* renders messages in batches with a loading indicator, maintaining full
* compatibility with the search functionality.
*
* Key Features:
* - Progressive rendering in configurable batches
* - Loading indicator during batch processing
* - Maintains search functionality compatibility
* - Smooth user experience with responsive UI
* - Configurable batch size and delay
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { Message } from '../types/message';
import GooseMessage from './GooseMessage';
import UserMessage from './UserMessage';
import { ContextHandler } from './context_management/ContextHandler';
import { useChatContextManager } from './context_management/ChatContextManager';
import { NotificationEvent } from '../hooks/useMessageStream';
import LoadingGoose from './LoadingGoose';
interface ProgressiveMessageListProps {
messages: Message[];
chat?: { id: string; messageHistoryIndex: number }; // Make optional for session history
toolCallNotifications?: Map<string, NotificationEvent[]>; // Make optional
append?: (value: string) => void; // Make optional
appendMessage?: (message: Message) => void; // Make optional
isUserMessage: (message: Message) => boolean;
onScrollToBottom?: () => void;
batchSize?: number;
batchDelay?: number;
showLoadingThreshold?: number; // Only show loading if more than X messages
// Custom render function for messages
renderMessage?: (message: Message, index: number) => React.ReactNode | null;
isStreamingMessage?: boolean; // Whether messages are currently being streamed
}
export default function ProgressiveMessageList({
messages,
chat,
toolCallNotifications = new Map(),
append = () => {},
appendMessage = () => {},
isUserMessage,
onScrollToBottom,
batchSize = 15, // Render 15 messages per batch (reduced for better UX)
batchDelay = 30, // 30ms delay between batches (faster)
showLoadingThreshold = 30, // Only show progressive loading for 30+ messages (lower threshold)
renderMessage, // Custom render function
isStreamingMessage = false, // Whether messages are currently being streamed
}: ProgressiveMessageListProps) {
const [renderedCount, setRenderedCount] = useState(() => {
// Initialize with either all messages (if small) or first batch (if large)
return messages.length <= showLoadingThreshold
? messages.length
: Math.min(batchSize, messages.length);
});
const [isLoading, setIsLoading] = useState(() => messages.length > showLoadingThreshold);
const timeoutRef = useRef<number | null>(null);
const mountedRef = useRef(true);
// Try to use context manager, but don't require it for session history
let hasContextHandlerContent: ((message: Message) => boolean) | undefined;
let getContextHandlerType:
| ((message: Message) => 'contextLengthExceeded' | 'summarizationRequested')
| undefined;
try {
const contextManager = useChatContextManager();
hasContextHandlerContent = contextManager.hasContextHandlerContent;
getContextHandlerType = contextManager.getContextHandlerType;
} catch (error) {
// Context manager not available (e.g., in session history view)
// This is fine, we'll just skip context handler functionality
hasContextHandlerContent = undefined;
getContextHandlerType = undefined;
}
// Simple progressive loading - start immediately when component mounts if needed
useEffect(() => {
if (messages.length <= showLoadingThreshold) {
setRenderedCount(messages.length);
setIsLoading(false);
return;
}
// Large list - start progressive loading
const loadNextBatch = () => {
setRenderedCount((current) => {
const nextCount = Math.min(current + batchSize, messages.length);
if (nextCount >= messages.length) {
setIsLoading(false);
// Trigger scroll to bottom
window.setTimeout(() => {
onScrollToBottom?.();
}, 100);
} else {
// Schedule next batch
timeoutRef.current = window.setTimeout(loadNextBatch, batchDelay);
}
return nextCount;
});
};
// Start loading after a short delay
timeoutRef.current = window.setTimeout(loadNextBatch, batchDelay);
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [
messages.length,
batchSize,
batchDelay,
showLoadingThreshold,
onScrollToBottom,
renderedCount,
]);
// Cleanup on unmount
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
// Force complete rendering when search is active
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isMac = window.electron.platform === 'darwin';
const isSearchShortcut = (isMac ? e.metaKey : e.ctrlKey) && e.key === 'f';
if (isSearchShortcut && isLoading) {
// Immediately render all messages when search is triggered
setRenderedCount(messages.length);
setIsLoading(false);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isLoading, messages.length]);
// Render messages up to the current rendered count
const renderMessages = useCallback(() => {
const messagesToRender = messages.slice(0, renderedCount);
const renderedMessages = messagesToRender
.map((message, index) => {
// Use custom render function if provided
if (renderMessage) {
return renderMessage(message, index);
}
// Default rendering logic (for BaseChat)
if (!chat) {
console.warn(
'ProgressiveMessageList: chat prop is required when not using custom renderMessage'
);
return null;
}
const isUser = isUserMessage(message);
const result = (
<div
key={message.id && `${message.id}-${message.content.length}`}
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'}`}
data-testid="message-container"
>
{isUser ? (
<>
{hasContextHandlerContent && hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType!(message)}
onSummaryComplete={() => {
window.setTimeout(() => onScrollToBottom?.(), 100);
}}
/>
) : (
<UserMessage message={message} />
)}
</>
) : (
<>
{hasContextHandlerContent && hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType!(message)}
onSummaryComplete={() => {
window.setTimeout(() => onScrollToBottom?.(), 100);
}}
/>
) : (
<GooseMessage
messageHistoryIndex={chat.messageHistoryIndex}
message={message}
messages={messages}
append={append}
appendMessage={appendMessage}
toolCallNotifications={toolCallNotifications}
isStreaming={
isStreamingMessage &&
!isUser &&
index === messagesToRender.length - 1 &&
message.role === 'assistant'
}
/>
)}
</>
)}
</div>
);
return result;
})
.filter(Boolean); // Filter out null values
return renderedMessages;
}, [
messages,
renderedCount,
renderMessage,
isUserMessage,
hasContextHandlerContent,
getContextHandlerType,
chat,
append,
appendMessage,
toolCallNotifications,
onScrollToBottom,
isStreamingMessage,
]);
return (
<>
{renderMessages()}
{/* Loading indicator when progressively rendering */}
{isLoading && (
<div className="flex flex-col items-center justify-center py-8">
<LoadingGoose message={`Loading messages... (${renderedCount}/${messages.length})`} />
<div className="text-xs text-text-muted mt-2">
Press Cmd/Ctrl+F to load all messages immediately for search
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConfig } from './ConfigContext';
interface ProviderGuardProps {
children: React.ReactNode;
}
export default function ProviderGuard({ children }: ProviderGuardProps) {
const { read } = useConfig();
const navigate = useNavigate();
const [isChecking, setIsChecking] = useState(true);
const [hasProvider, setHasProvider] = useState(false);
useEffect(() => {
const checkProvider = async () => {
try {
const config = window.electron.getConfig();
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (provider && model) {
setHasProvider(true);
} else {
console.log('No provider/model configured, redirecting to welcome');
navigate('/welcome', { replace: true });
}
} catch (error) {
console.error('Error checking provider configuration:', error);
// On error, assume no provider and redirect to welcome
navigate('/welcome', { replace: true });
} finally {
setIsChecking(false);
}
};
checkProvider();
}, [read, navigate]);
if (isChecking) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
</div>
);
}
if (!hasProvider) {
// This will be handled by the navigation above, but we return null to be safe
return null;
}
return <>{children}</>;
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from './ui/button';
export default function RecipeActivityEditor({ export default function RecipeActivityEditor({
activities, activities,
@@ -31,16 +32,18 @@ export default function RecipeActivityEditor({
{activities.map((activity, index) => ( {activities.map((activity, index) => (
<div <div
key={index} key={index}
className="inline-flex items-center bg-bgApp border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard" className="inline-flex items-center bg-background-default border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
title={activity.length > 100 ? activity : undefined} title={activity.length > 100 ? activity : undefined}
> >
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span> <span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
<button <Button
onClick={() => handleRemoveActivity(activity)} onClick={() => handleRemoveActivity(activity)}
className="ml-2 text-textStandard hover:text-textSubtle transition-colors" variant="ghost"
size="sm"
className="ml-2 text-textStandard hover:text-textSubtle transition-colors p-0 h-auto"
> >
× ×
</button> </Button>
</div> </div>
))} ))}
</div> </div>
@@ -50,15 +53,15 @@ export default function RecipeActivityEditor({
value={newActivity} value={newActivity}
onChange={(e) => setNewActivity(e.target.value)} onChange={(e) => setNewActivity(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()} onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
className="flex-1 px-4 py-3 border rounded-lg bg-bgApp text-textStandard placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent" className="flex-1 px-4 py-3 border rounded-lg bg-background-default text-textStandard placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Add new activity..." placeholder="Add new activity..."
/> />
<button <Button
onClick={handleAddActivity} onClick={handleAddActivity}
className="px-5 py-1.5 text-sm bg-bgAppInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors" className="px-5 py-1.5 text-sm bg-background-defaultInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
> >
Add activity Add activity
</button> </Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Recipe } from '../recipe'; import { Recipe } from '../recipe';
import { Parameter } from '../recipe/index'; import { Parameter } from '../recipe/index';
@@ -16,6 +17,7 @@ import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
import ParameterInput from './parameter/ParameterInput'; import ParameterInput from './parameter/ParameterInput';
import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage'; import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
import { toastSuccess, toastError } from '../toasts'; import { toastSuccess, toastError } from '../toasts';
import { Button } from './ui/button';
interface RecipeEditorProps { interface RecipeEditorProps {
config?: Recipe; config?: Recipe;
@@ -30,6 +32,7 @@ function generateDeepLink(recipe: Recipe): string {
export default function RecipeEditor({ config }: RecipeEditorProps) { export default function RecipeEditor({ config }: RecipeEditorProps) {
const { getExtensions } = useConfig(); const { getExtensions } = useConfig();
const navigate = useNavigate();
const [recipeConfig] = useState<Recipe | undefined>(config); const [recipeConfig] = useState<Recipe | undefined>(config);
const [title, setTitle] = useState(config?.title || ''); const [title, setTitle] = useState(config?.title || '');
const [description, setDescription] = useState(config?.description || ''); const [description, setDescription] = useState(config?.description || '');
@@ -321,10 +324,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
} }
return ( return (
<div className="flex flex-col w-full h-screen bg-bgApp max-w-3xl mx-auto"> <div className="flex flex-col w-full h-screen bg-background-default">
{activeSection === 'none' && ( {activeSection === 'none' && (
<div className="flex flex-col items-center mb-2 px-6 pt-10"> <div className="flex flex-col items-center mb-2 px-6 pt-10">
<div className="w-16 h-16 bg-bgApp rounded-full flex items-center justify-center mb-4"> <div className="w-16 h-16 bg-background-default rounded-full flex items-center justify-center mb-4">
<Geese className="w-12 h-12 text-iconProminent" /> <Geese className="w-12 h-12 text-iconProminent" />
</div> </div>
<h1 className="text-2xl font-medium text-center text-textProminent">{page_title}</h1> <h1 className="text-2xl font-medium text-center text-textProminent">{page_title}</h1>
@@ -349,7 +352,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
setErrors({ ...errors, title: undefined }); setErrors({ ...errors, title: undefined });
} }
}} }}
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${ className={`w-full max-w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent overflow-hidden ${
errors.title ? 'border-red-500' : 'border-borderSubtle' errors.title ? 'border-red-500' : 'border-borderSubtle'
}`} }`}
placeholder="Agent Recipe Title (required)" placeholder="Agent Recipe Title (required)"
@@ -372,7 +375,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
setErrors({ ...errors, description: undefined }); setErrors({ ...errors, description: undefined });
} }
}} }}
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${ className={`w-full max-w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent overflow-hidden ${
errors.description ? 'border-red-500' : 'border-borderSubtle' errors.description ? 'border-red-500' : 'border-borderSubtle'
}`} }`}
placeholder="Description (required)" placeholder="Description (required)"
@@ -420,19 +423,21 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
</div> </div>
{/* Deep Link Display */} {/* Deep Link Display */}
<div className="w-full p-4 bg-bgSubtle rounded-lg"> <div className="w-full p-4 bg-bgSubtle rounded-lg overflow-hidden">
{!requiredFieldsAreFilled() ? ( {!requiredFieldsAreFilled() ? (
<div className="text-sm text-textSubtle text-xs text-textSubtle"> <div className="text-sm text-textSubtle text-xs text-textSubtle">
Fill in required fields to generate link Fill in required fields to generate link
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2 gap-4">
<div className="text-sm text-textSubtle text-xs text-textSubtle"> <div className="text-sm text-textSubtle text-xs text-textSubtle flex-shrink-0">
Copy this link to share with friends or paste directly in Chrome to open Copy this link to share with friends or paste directly in Chrome to open
</div> </div>
<button <Button
onClick={() => validateForm() && handleCopy()} onClick={() => validateForm() && handleCopy()}
className="ml-4 p-2 hover:bg-bgApp rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent" variant="ghost"
size="sm"
className="p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent flex-shrink-0"
> >
{copied ? ( {copied ? (
<Check className="w-4 h-4 text-green-500" /> <Check className="w-4 h-4 text-green-500" />
@@ -442,16 +447,19 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
<span className="ml-1 text-sm text-textSubtle"> <span className="ml-1 text-sm text-textSubtle">
{copied ? 'Copied!' : 'Copy'} {copied ? 'Copied!' : 'Copy'}
</span> </span>
</button> </Button>
</div> </div>
)} )}
{requiredFieldsAreFilled() && ( {requiredFieldsAreFilled() && (
<div className="w-full overflow-hidden">
<div <div
onClick={() => validateForm() && handleCopy()} onClick={() => validateForm() && handleCopy()}
className={`text-sm truncate dark:text-white font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`} className={`text-sm dark:text-white font-mono cursor-pointer hover:bg-background-default p-2 rounded transition-colors overflow-x-auto whitespace-nowrap ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
style={{ maxWidth: '500px', width: '100%' }}
> >
{deeplink} {deeplink}
</div> </div>
</div>
)} )}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
@@ -465,24 +473,27 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
<Save className="w-4 h-4" /> <Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Recipe'} {saving ? 'Saving...' : 'Save Recipe'}
</button> </button>
<button <Button
onClick={() => setIsScheduleModalOpen(true)} onClick={() => setIsScheduleModalOpen(true)}
disabled={!requiredFieldsAreFilled()} disabled={!requiredFieldsAreFilled()}
variant="outline"
size="lg"
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
Create Schedule Create Schedule
</button> </Button>
</div> </div>
<button <Button
onClick={() => { onClick={() => {
localStorage.removeItem('recipe_editor_extensions'); localStorage.removeItem('recipe_editor_extensions');
window.close(); navigate(-1);
}} }}
variant="ghost"
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors" className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
> >
Close Close
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -499,24 +510,16 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
onClose={() => setIsScheduleModalOpen(false)} onClose={() => setIsScheduleModalOpen(false)}
recipe={getCurrentConfig()} recipe={getCurrentConfig()}
onCreateSchedule={(deepLink) => { onCreateSchedule={(deepLink) => {
// Open the schedules view with the deep link pre-filled // Navigate to the schedules view with the deep link pre-filled
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
undefined,
'schedules'
);
// Store the deep link in localStorage for the schedules view to pick up
localStorage.setItem('pendingScheduleDeepLink', deepLink); localStorage.setItem('pendingScheduleDeepLink', deepLink);
navigate('/schedules');
}} }}
/> />
{/* Save Recipe Dialog */} {/* Save Recipe Dialog */}
{showSaveDialog && ( {showSaveDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50"> <div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]"> <div className="bg-background-default border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3> <h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
<div className="space-y-4"> <div className="space-y-4">
@@ -532,7 +535,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
type="text" type="text"
value={saveRecipeName} value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)} onChange={(e) => setSaveRecipeName(e.target.value)}
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent" className="w-full p-3 border border-borderSubtle rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Enter recipe name" placeholder="Enter recipe name"
autoFocus autoFocus
/> />

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { Button } from './ui/button';
interface RecipeExpandableInfoProps { interface RecipeExpandableInfoProps {
infoLabel: string; infoLabel: string;
@@ -38,7 +39,7 @@ export default function RecipeExpandableInfo({
</label> </label>
</div> </div>
<div className="relative rounded-lg bg-bgApp text-textStandard"> <div className="relative rounded-lg bg-background-default text-textStandard">
{infoValue && ( {infoValue && (
<> <>
<div <div
@@ -60,25 +61,27 @@ export default function RecipeExpandableInfo({
</> </>
)} )}
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
<button <Button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setValueExpanded(true); setValueExpanded(true);
onClickEdit(); onClickEdit();
}} }}
className="w-36 px-3 py-3 bg-bgAppInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors" className="w-36 px-3 py-3 bg-background-defaultInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
> >
{infoValue ? 'Edit' : 'Add'} {infoLabel.toLowerCase()} {infoValue ? 'Edit' : 'Add'} {infoLabel.toLowerCase()}
</button> </Button>
{infoValue && isClamped && ( {infoValue && isClamped && (
<button <Button
type="button" type="button"
variant="ghost"
shape="round"
onClick={() => setValueExpanded(!isValueExpanded)} onClick={() => setValueExpanded(!isValueExpanded)}
aria-label={isValueExpanded ? 'Collapse content' : 'Expand content'} aria-label={isValueExpanded ? 'Collapse content' : 'Expand content'}
title={isValueExpanded ? 'Collapse' : 'Expand'} title={isValueExpanded ? 'Collapse' : 'Expand'}
className="bg-bgSubtle hover:bg-bgStandard p-2 rounded text-textStandard hover:text-textProminent transition-colors" className="bg-background-muted hover:bg-background-default text-text-muted hover:text-text-default transition-colors"
> >
<ChevronDown <ChevronDown
className={`w-6 h-6 transition-transform duration-300 ${ className={`w-6 h-6 transition-transform duration-300 ${
@@ -86,7 +89,7 @@ export default function RecipeExpandableInfo({
}`} }`}
strokeWidth={2.5} strokeWidth={2.5}
/> />
</button> </Button>
)} )}
</div> </div>
</div> </div>

View File

@@ -34,14 +34,14 @@ export default function RecipeInfoModal({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] z-[1000]"> <div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] z-[1000]">
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col min-w-[80%] min-h-[80%] bg-bgApp rounded-xl overflow-hidden shadow-lg px-8 pt-[24px] pb-0"> <Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col min-w-[80%] min-h-[80%] bg-background-default rounded-xl overflow-hidden shadow-lg px-8 pt-[24px] pb-0">
<div className="flex mb-6"> <div className="flex mb-6">
<h2 className="text-xl font-semibold text-textProminent">Edit {infoLabel}</h2> <h2 className="text-xl font-semibold text-textProminent">Edit {infoLabel}</h2>
</div> </div>
<div className="flex flex-col flex-grow overflow-y-auto space-y-8"> <div className="flex flex-col flex-grow overflow-y-auto space-y-8">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
className="w-full flex-grow resize-none min-h-[300px] max-h-[calc(100vh-300px)] border border-borderSubtle rounded-lg p-3 text-textStandard bg-bgApp focus:outline-none focus:ring-1 focus:ring-borderProminent focus:border-borderProminent" className="w-full flex-grow resize-none min-h-[300px] max-h-[calc(100vh-300px)] border border-borderSubtle rounded-lg p-3 text-textStandard bg-background-default focus:outline-none focus:ring-1 focus:ring-borderProminent focus:border-borderProminent"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder={`Enter ${infoLabel.toLowerCase()}...`} placeholder={`Enter ${infoLabel.toLowerCase()}...`}

View File

@@ -6,37 +6,80 @@ import {
saveRecipe, saveRecipe,
generateRecipeFilename, generateRecipeFilename,
} from '../recipe/recipeStorage'; } from '../recipe/recipeStorage';
import { FileText, Trash2, Bot, Calendar, Globe, Folder, Download } from 'lucide-react'; import {
FileText,
Trash2,
Bot,
Calendar,
Globe,
Folder,
AlertCircle,
Download,
} from 'lucide-react';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
import BackButton from './ui/BackButton'; import { Card } from './ui/card';
import MoreMenuLayout from './more_menu/MoreMenuLayout'; import { Button } from './ui/button';
import { Skeleton } from './ui/skeleton';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import { Recipe } from '../recipe'; import { Recipe } from '../recipe';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { toastSuccess, toastError } from '../toasts'; import { toastSuccess, toastError } from '../toasts';
interface RecipesViewProps { interface RecipesViewProps {
onBack: () => void; onLoadRecipe?: (recipe: Recipe) => void;
} }
export default function RecipesView({ onBack }: RecipesViewProps) { export default function RecipesView({ onLoadRecipe }: RecipesViewProps = {}) {
const [savedRecipes, setSavedRecipes] = useState<SavedRecipe[]>([]); const [savedRecipes, setSavedRecipes] = useState<SavedRecipe[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showSkeleton, setShowSkeleton] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(null); const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(null);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [showContent, setShowContent] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false);
const [importDeeplink, setImportDeeplink] = useState(''); const [importDeeplink, setImportDeeplink] = useState('');
const [importRecipeName, setImportRecipeName] = useState(''); const [importRecipeName, setImportRecipeName] = useState('');
const [importGlobal, setImportGlobal] = useState(true); const [importGlobal, setImportGlobal] = useState(true);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
// Create Recipe state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [createTitle, setCreateTitle] = useState('');
const [createDescription, setCreateDescription] = useState('');
const [createInstructions, setCreateInstructions] = useState('');
const [createPrompt, setCreatePrompt] = useState('');
const [createActivities, setCreateActivities] = useState('');
const [createRecipeName, setCreateRecipeName] = useState('');
const [createGlobal, setCreateGlobal] = useState(true);
const [creating, setCreating] = useState(false);
useEffect(() => { useEffect(() => {
loadSavedRecipes(); loadSavedRecipes();
}, []); }, []);
// 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 loadSavedRecipes = async () => { const loadSavedRecipes = async () => {
try { try {
setLoading(true); setLoading(true);
setShowSkeleton(true);
setShowContent(false);
setError(null); setError(null);
const recipes = await listSavedRecipes(); const recipes = await listSavedRecipes();
setSavedRecipes(recipes); setSavedRecipes(recipes);
@@ -50,7 +93,11 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
const handleLoadRecipe = async (savedRecipe: SavedRecipe) => { const handleLoadRecipe = async (savedRecipe: SavedRecipe) => {
try { try {
// Use the recipe directly - no need for manual mapping if (onLoadRecipe) {
// Use the callback to navigate within the same window
onLoadRecipe(savedRecipe.recipe);
} else {
// Fallback to creating a new window (for backwards compatibility)
window.electron.createChatWindow( window.electron.createChatWindow(
undefined, // query undefined, // query
undefined, // dir undefined, // dir
@@ -59,6 +106,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
savedRecipe.recipe, // recipe config savedRecipe.recipe, // recipe config
undefined // view type undefined // view type
); );
}
} catch (err) { } catch (err) {
console.error('Failed to load recipe:', err); console.error('Failed to load recipe:', err);
setError(err instanceof Error ? err.message : 'Failed to load recipe'); setError(err instanceof Error ? err.message : 'Failed to load recipe');
@@ -186,13 +234,197 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
} }
}; };
if (loading) { // Create Recipe handlers
const handleCreateClick = () => {
// Reset form with example values
setCreateTitle('Python Development Assistant');
setCreateDescription(
'A helpful assistant for Python development tasks including coding, debugging, and code review.'
);
setCreateInstructions(`You are an expert Python developer assistant. Help users with:
1. Writing clean, efficient Python code
2. Debugging and troubleshooting issues
3. Code review and optimization suggestions
4. Best practices and design patterns
5. Testing and documentation
Always provide clear explanations and working code examples.
Parameters you can use:
- {{project_type}}: The type of Python project (web, data science, CLI, etc.)
- {{python_version}}: Target Python version`);
setCreatePrompt('What Python development task can I help you with today?');
setCreateActivities('coding, debugging, testing, documentation');
setCreateRecipeName('');
setCreateGlobal(true);
setShowCreateDialog(true);
};
const handleCreateRecipe = async () => {
if (
!createTitle.trim() ||
!createDescription.trim() ||
!createInstructions.trim() ||
!createRecipeName.trim()
) {
return;
}
setCreating(true);
try {
// Parse activities from comma-separated string
const activities = createActivities
.split(',')
.map((activity) => activity.trim())
.filter((activity) => activity.length > 0);
// Create the recipe object
const recipe: Recipe = {
title: createTitle.trim(),
description: createDescription.trim(),
instructions: createInstructions.trim(),
prompt: createPrompt.trim() || undefined,
activities: activities.length > 0 ? activities : undefined,
};
await saveRecipe(recipe, {
name: createRecipeName.trim(),
global: createGlobal,
});
// Reset dialog state
setShowCreateDialog(false);
setCreateTitle('');
setCreateDescription('');
setCreateInstructions('');
setCreatePrompt('');
setCreateActivities('');
setCreateRecipeName('');
await loadSavedRecipes();
toastSuccess({
title: createRecipeName.trim(),
msg: 'Recipe created successfully',
});
} catch (error) {
console.error('Failed to create recipe:', error);
toastError({
title: 'Create Failed',
msg: `Failed to create recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setCreating(false);
}
};
// Auto-generate recipe name when title changes
const handleCreateTitleChange = (value: string) => {
setCreateTitle(value);
if (value.trim() && !createRecipeName.trim()) {
const suggestedName = value
.toLowerCase()
.replace(/[^a-zA-Z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.trim();
setCreateRecipeName(suggestedName);
}
};
// Render a recipe item
const RecipeItem = ({ savedRecipe }: { savedRecipe: SavedRecipe }) => (
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base truncate max-w-[50vw]">{savedRecipe.recipe.title}</h3>
{savedRecipe.isGlobal ? (
<Globe className="w-4 h-4 text-text-muted flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-text-muted flex-shrink-0" />
)}
</div>
<p className="text-text-muted text-sm mb-2 line-clamp-2">
{savedRecipe.recipe.description}
</p>
<div className="flex items-center text-xs text-text-muted">
<Calendar className="w-3 h-3 mr-1" />
{savedRecipe.lastModified.toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
onClick={(e) => {
e.stopPropagation();
handleLoadRecipe(savedRecipe);
}}
size="sm"
className="h-8"
>
<Bot className="w-4 h-4 mr-1" />
Use
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handlePreviewRecipe(savedRecipe);
}}
variant="outline"
size="sm"
className="h-8"
>
<FileText className="w-4 h-4 mr-1" />
Preview
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleDeleteRecipe(savedRecipe);
}}
variant="ghost"
size="sm"
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
// Render skeleton loader for recipe items
const RecipeSkeleton = () => (
<Card className="p-2 mb-2 bg-background-default">
<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-16" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-8" />
</div>
</div>
</Card>
);
const renderContent = () => {
if (loading || showSkeleton) {
return ( return (
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]"> <div className="space-y-6">
<MoreMenuLayout showMenu={false} /> <div className="space-y-3">
<div className="flex flex-col items-center justify-center h-full"> <Skeleton className="h-6 w-24" />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-borderProminent"></div> <div className="space-y-2">
<p className="mt-4 text-textSubtle">Loading recipes...</p> <RecipeSkeleton />
<RecipeSkeleton />
<RecipeSkeleton />
</div>
</div> </div>
</div> </div>
); );
@@ -200,128 +432,104 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
if (error) { if (error) {
return ( return (
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]"> <div className="flex flex-col items-center justify-center h-full text-text-muted">
<MoreMenuLayout showMenu={false} /> <AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<div className="flex flex-col items-center justify-center h-full"> <p className="text-lg mb-2">Error Loading Recipes</p>
<p className="text-red-500 mb-4">{error}</p> <p className="text-sm text-center mb-4">{error}</p>
<button <Button onClick={loadSavedRecipes} variant="default">
onClick={loadSavedRecipes} Try Again
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90" </Button>
>
Retry
</button>
</div> </div>
);
}
if (savedRecipes.length === 0) {
return (
<div className="flex flex-col justify-center pt-2 h-full">
<p className="text-lg">No saved recipes</p>
<p className="text-sm text-text-muted">Recipe saved from chats will show up here.</p>
</div> </div>
); );
} }
return ( return (
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]"> <div className="space-y-2">
<MoreMenuLayout showMenu={false} /> {savedRecipes.map((savedRecipe) => (
<RecipeItem
key={`${savedRecipe.isGlobal ? 'global' : 'local'}-${savedRecipe.name}`}
savedRecipe={savedRecipe}
/>
))}
</div>
);
};
<ScrollArea className="h-full w-full"> return (
<div className="flex flex-col pb-24"> <>
<div className="px-8 pt-6 pb-4"> <MainPanelLayout>
<BackButton onClick={onBack} /> <div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mt-1"> <div className="bg-background-default px-8 pb-8 pt-16">
<h1 className="text-3xl font-medium text-textStandard">Saved Recipes</h1> <div className="flex flex-col page-transition">
<button <div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Recipes</h1>
<div className="flex gap-2">
<Button
onClick={handleCreateClick}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Create Recipe
</Button>
<Button
onClick={handleImportClick} onClick={handleImportClick}
className="flex items-center gap-2 px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors text-sm font-medium" variant="default"
size="sm"
className="flex items-center gap-2"
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
Import Recipe Import Recipe
</button> </Button>
</div>
</div>
<p className="text-sm text-text-muted mb-1">
View and manage your saved recipes to quickly start new sessions with predefined
configurations.
</p>
</div> </div>
</div> </div>
{/* Content Area */} <div className="flex-1 min-h-0 relative px-8">
<div className="flex-1 pt-[20px]"> <ScrollArea className="h-full">
{savedRecipes.length === 0 ? ( <div
<div className="flex flex-col items-center justify-center h-full text-center px-8"> className={`h-full relative transition-all duration-300 ${
<FileText className="w-16 h-16 text-textSubtle mb-4" /> showContent ? 'opacity-100 animate-in fade-in ' : 'opacity-0'
<h3 className="text-lg font-medium text-textStandard mb-2">No saved recipes</h3> }`}
<p className="text-textSubtle">
Create and save recipes from the Recipe Editor to see them here.
</p>
<p className="text-textSubtle text-sm mt-2">
You can also save recipes from active recipe sessions using the Settings menu.
</p>
</div>
) : (
<div className="space-y-8 px-8">
{savedRecipes.map((savedRecipe) => (
<section
key={`${savedRecipe.isGlobal ? 'global' : 'local'}-${savedRecipe.name}`}
className="border-b border-borderSubtle pb-8"
> >
<div className="flex justify-between items-start mb-4"> {renderContent()}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-xl font-medium text-textStandard">
{savedRecipe.recipe.title}
</h3>
{savedRecipe.isGlobal ? (
<Globe className="w-4 h-4 text-textSubtle" />
) : (
<Folder className="w-4 h-4 text-textSubtle" />
)}
</div>
<p className="text-textSubtle mb-2">{savedRecipe.recipe.description}</p>
<div className="flex items-center text-xs text-textSubtle">
<Calendar className="w-3 h-3 mr-1" />
{savedRecipe.lastModified.toLocaleDateString()}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => handleLoadRecipe(savedRecipe)}
className="flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg hover:bg-opacity-90 transition-colors text-sm font-medium"
>
<Bot className="w-4 h-4" />
Use Recipe
</button>
<button
onClick={() => handlePreviewRecipe(savedRecipe)}
className="flex items-center gap-2 px-4 py-2 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors text-sm"
>
<FileText className="w-4 h-4" />
Preview
</button>
<button
onClick={() => handleDeleteRecipe(savedRecipe)}
className="flex items-center gap-2 px-4 py-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors text-sm"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
</section>
))}
</div>
)}
</div>
</div> </div>
</ScrollArea> </ScrollArea>
</div>
</div>
</MainPanelLayout>
{/* Preview Modal */} {/* Preview Modal */}
{showPreview && selectedRecipe && ( {showPreview && selectedRecipe && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50"> <div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto"> <div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div> <div>
<h3 className="text-xl font-medium text-textStandard"> <h3 className="text-xl font-medium text-text-standard">
{selectedRecipe.recipe.title} {selectedRecipe.recipe.title}
</h3> </h3>
<p className="text-sm text-textSubtle"> <p className="text-sm text-text-muted">
{selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'} {selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'}
</p> </p>
</div> </div>
<button <button
onClick={() => setShowPreview(false)} onClick={() => setShowPreview(false)}
className="text-textSubtle hover:text-textStandard text-2xl leading-none" className="text-text-muted hover:text-text-standard text-2xl leading-none"
> >
× ×
</button> </button>
@@ -329,15 +537,15 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h4 className="text-sm font-medium text-textStandard mb-2">Description</h4> <h4 className="text-sm font-medium text-text-standard mb-2">Description</h4>
<p className="text-textSubtle">{selectedRecipe.recipe.description}</p> <p className="text-text-muted">{selectedRecipe.recipe.description}</p>
</div> </div>
{selectedRecipe.recipe.instructions && ( {selectedRecipe.recipe.instructions && (
<div> <div>
<h4 className="text-sm font-medium text-textStandard mb-2">Instructions</h4> <h4 className="text-sm font-medium text-text-standard mb-2">Instructions</h4>
<div className="bg-bgSubtle border border-borderSubtle p-3 rounded-lg"> <div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<pre className="text-sm text-textSubtle whitespace-pre-wrap font-mono"> <pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
{selectedRecipe.recipe.instructions} {selectedRecipe.recipe.instructions}
</pre> </pre>
</div> </div>
@@ -346,9 +554,9 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
{selectedRecipe.recipe.prompt && ( {selectedRecipe.recipe.prompt && (
<div> <div>
<h4 className="text-sm font-medium text-textStandard mb-2">Initial Prompt</h4> <h4 className="text-sm font-medium text-text-standard mb-2">Initial Prompt</h4>
<div className="bg-bgSubtle border border-borderSubtle p-3 rounded-lg"> <div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
<pre className="text-sm text-textSubtle whitespace-pre-wrap font-mono"> <pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
{selectedRecipe.recipe.prompt} {selectedRecipe.recipe.prompt}
</pre> </pre>
</div> </div>
@@ -357,12 +565,12 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
{selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && ( {selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && (
<div> <div>
<h4 className="text-sm font-medium text-textStandard mb-2">Activities</h4> <h4 className="text-sm font-medium text-text-standard mb-2">Activities</h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedRecipe.recipe.activities.map((activity, index) => ( {selectedRecipe.recipe.activities.map((activity, index) => (
<span <span
key={index} key={index}
className="px-2 py-1 bg-bgSubtle border border-borderSubtle text-textSubtle rounded text-sm" className="px-2 py-1 bg-background-muted border border-border-subtle text-text-muted rounded text-sm"
> >
{activity} {activity}
</span> </span>
@@ -372,22 +580,19 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
)} )}
</div> </div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-borderSubtle"> <div className="flex justify-end gap-3 mt-6 pt-4 border-t border-border-subtle">
<button <Button onClick={() => setShowPreview(false)} variant="ghost">
onClick={() => setShowPreview(false)}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
>
Close Close
</button> </Button>
<button <Button
onClick={() => { onClick={() => {
setShowPreview(false); setShowPreview(false);
handleLoadRecipe(selectedRecipe); handleLoadRecipe(selectedRecipe);
}} }}
className="px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg hover:bg-opacity-90 transition-colors font-medium" variant="default"
> >
Load Recipe Load Recipe
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -395,15 +600,15 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
{/* Import Recipe Dialog */} {/* Import Recipe Dialog */}
{showImportDialog && ( {showImportDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50"> <div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-[500px] max-w-[90vw]"> <div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Import Recipe</h3> <h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label <label
htmlFor="import-deeplink" htmlFor="import-deeplink"
className="block text-sm font-medium text-textStandard mb-2" className="block text-sm font-medium text-text-standard mb-2"
> >
Recipe Deeplink Recipe Deeplink
</label> </label>
@@ -411,12 +616,12 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
id="import-deeplink" id="import-deeplink"
value={importDeeplink} value={importDeeplink}
onChange={(e) => handleDeeplinkChange(e.target.value)} onChange={(e) => handleDeeplinkChange(e.target.value)}
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent resize-none" className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
placeholder="Paste your goose://recipe?config=... deeplink here" placeholder="Paste your goose://recipe?config=... deeplink here"
rows={3} rows={3}
autoFocus autoFocus
/> />
<p className="text-xs text-textSubtle mt-1"> <p className="text-xs text-text-muted mt-1">
Paste a recipe deeplink starting with "goose://recipe?config=" Paste a recipe deeplink starting with "goose://recipe?config="
</p> </p>
</div> </div>
@@ -424,7 +629,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
<div> <div>
<label <label
htmlFor="import-recipe-name" htmlFor="import-recipe-name"
className="block text-sm font-medium text-textStandard mb-2" className="block text-sm font-medium text-text-standard mb-2"
> >
Recipe Name Recipe Name
</label> </label>
@@ -433,13 +638,13 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
type="text" type="text"
value={importRecipeName} value={importRecipeName}
onChange={(e) => setImportRecipeName(e.target.value)} onChange={(e) => setImportRecipeName(e.target.value)}
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent" className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter a name for the imported recipe" placeholder="Enter a name for the imported recipe"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-textStandard mb-2"> <label className="block text-sm font-medium text-text-standard mb-2">
Save Location Save Location
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@@ -451,7 +656,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
onChange={() => setImportGlobal(true)} onChange={() => setImportGlobal(true)}
className="mr-2" className="mr-2"
/> />
<span className="text-sm text-textStandard"> <span className="text-sm text-text-standard">
Global - Available across all Goose sessions Global - Available across all Goose sessions
</span> </span>
</label> </label>
@@ -463,7 +668,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
onChange={() => setImportGlobal(false)} onChange={() => setImportGlobal(false)}
className="mr-2" className="mr-2"
/> />
<span className="text-sm text-textStandard"> <span className="text-sm text-text-standard">
Directory - Available in the working directory Directory - Available in the working directory
</span> </span>
</label> </label>
@@ -472,28 +677,211 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
</div> </div>
<div className="flex justify-end space-x-3 mt-6"> <div className="flex justify-end space-x-3 mt-6">
<button <Button
onClick={() => { onClick={() => {
setShowImportDialog(false); setShowImportDialog(false);
setImportDeeplink(''); setImportDeeplink('');
setImportRecipeName(''); setImportRecipeName('');
}} }}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors" variant="ghost"
disabled={importing} disabled={importing}
> >
Cancel Cancel
</button> </Button>
<button <Button
onClick={handleImportRecipe} onClick={handleImportRecipe}
disabled={!importDeeplink.trim() || !importRecipeName.trim() || importing} disabled={!importDeeplink.trim() || !importRecipeName.trim() || importing}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" variant="default"
> >
{importing ? 'Importing...' : 'Import Recipe'} {importing ? 'Importing...' : 'Import Recipe'}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* Create Recipe Dialog */}
{showCreateDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[700px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-text-standard mb-4">Create New Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="create-title"
className="block text-sm font-medium text-text-standard mb-2"
>
Title <span className="text-red-500">*</span>
</label>
<input
id="create-title"
type="text"
value={createTitle}
onChange={(e) => handleCreateTitleChange(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Recipe title"
autoFocus
/>
</div> </div>
<div>
<label
htmlFor="create-description"
className="block text-sm font-medium text-text-standard mb-2"
>
Description <span className="text-red-500">*</span>
</label>
<input
id="create-description"
type="text"
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Brief description of what this recipe does"
/>
</div>
<div>
<label
htmlFor="create-instructions"
className="block text-sm font-medium text-text-standard mb-2"
>
Instructions <span className="text-red-500">*</span>
</label>
<textarea
id="create-instructions"
value={createInstructions}
onChange={(e) => setCreateInstructions(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm"
placeholder="Detailed instructions for the AI agent..."
rows={8}
/>
<p className="text-xs text-text-muted mt-1">
Use {`{{parameter_name}}`} to define parameters that users can fill in
</p>
</div>
<div>
<label
htmlFor="create-prompt"
className="block text-sm font-medium text-text-standard mb-2"
>
Initial Prompt (Optional)
</label>
<textarea
id="create-prompt"
value={createPrompt}
onChange={(e) => setCreatePrompt(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
placeholder="First message to send when the recipe starts..."
rows={3}
/>
</div>
<div>
<label
htmlFor="create-activities"
className="block text-sm font-medium text-text-standard mb-2"
>
Activities (Optional)
</label>
<input
id="create-activities"
type="text"
value={createActivities}
onChange={(e) => setCreateActivities(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="coding, debugging, testing, documentation (comma-separated)"
/>
<p className="text-xs text-text-muted mt-1">
Comma-separated list of activities this recipe helps with
</p>
</div>
<div>
<label
htmlFor="create-recipe-name"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Name <span className="text-red-500">*</span>
</label>
<input
id="create-recipe-name"
type="text"
value={createRecipeName}
onChange={(e) => setCreateRecipeName(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="File name for the recipe"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="create-save-location"
checked={createGlobal}
onChange={() => setCreateGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="create-save-location"
checked={!createGlobal}
onChange={() => setCreateGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={() => {
setShowCreateDialog(false);
setCreateTitle('');
setCreateDescription('');
setCreateInstructions('');
setCreatePrompt('');
setCreateActivities('');
setCreateRecipeName('');
}}
variant="ghost"
disabled={creating}
>
Cancel
</Button>
<Button
onClick={handleCreateRecipe}
disabled={
!createTitle.trim() ||
!createDescription.trim() ||
!createInstructions.trim() ||
!createRecipeName.trim() ||
creating
}
variant="default"
>
{creating ? 'Creating...' : 'Create Recipe'}
</Button>
</div>
</div>
</div>
)}
</>
); );
} }

View File

@@ -1,6 +1,11 @@
import SplashPills from './SplashPills'; import { Card } from './ui/card';
import { gsap } from 'gsap';
import { Greeting } from './common/Greeting';
import GooseLogo from './GooseLogo'; import GooseLogo from './GooseLogo';
// Register GSAP plugins
gsap.registerPlugin();
interface SplashProps { interface SplashProps {
append: (text: string) => void; append: (text: string) => void;
activities: string[] | null; activities: string[] | null;
@@ -8,31 +13,65 @@ interface SplashProps {
} }
export default function Splash({ append, activities, title }: SplashProps) { export default function Splash({ append, activities, title }: SplashProps) {
const pills = activities || [];
// 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;
// If we have activities (recipe mode), show a simplified version without greeting
if (activities && activities.length > 0) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col px-6">
{/* Animated goose icon */}
<div className="flex justify-start mb-6">
<GooseLogo size="default" hover={true} />
</div>
{messagePill && (
<div className="mb-4 p-3 rounded-lg border animate-[fadein_500ms_ease-in_forwards]">
{messagePill.replace(/^message:/i, '').trim()}
</div>
)}
<div className="flex flex-wrap gap-2 animate-[fadein_500ms_ease-in_forwards]">
{remainingPills.map((content, index) => (
<Card
key={index}
onClick={() => append(content)}
title={content.length > 60 ? content : undefined}
className="cursor-pointer px-3 py-1.5 text-sm hover:bg-bgSubtle transition-colors"
>
{content.length > 60 ? content.slice(0, 60) + '...' : content}
</Card>
))}
</div>
</div>
);
}
// Default splash screen (no recipe) - show greeting and title if provided
return (
<div className="flex flex-col">
{title && ( {title && (
<div className="flex items-center px-4 py-2"> <div className="flex items-center px-4 py-2 mb-4">
<span className="w-2 h-2 rounded-full bg-blockTeal mr-2" /> <span className="w-2 h-2 rounded-full bg-blockTeal mr-2" />
<span className="text-sm"> <span className="text-sm">
<span className="text-textSubtle">Agent</span>{' '} <span className="text-text-muted">Agent</span>{' '}
<span className="text-textStandard">{title}</span> <span className="text-text-default">{title}</span>
</span> </span>
</div> </div>
)} )}
<div className="flex flex-col flex-1">
<div className="h-full flex flex-col pb-12">
<div className="p-8">
<div className="relative text-textStandard mb-12">
<div className="w-min animate-[flyin_2s_var(--spring-easing)_forwards]">
<GooseLogo />
</div>
</div>
<div> {/* Compact greeting section */}
<SplashPills append={append} activities={activities} /> <div className="flex flex-col px-6 mb-0">
</div> <Greeting className="text-text-prominent text-4xl font-light mb-2" />
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -28,7 +28,7 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
if (!needsExpansion) { if (!needsExpansion) {
return ( return (
<div className="text-sm mb-2"> <div className="text-xs mb-2">
<div className="flex flex-row"> <div className="flex flex-row">
<span className="text-textSubtle min-w-[140px]">{key}</span> <span className="text-textSubtle min-w-[140px]">{key}</span>
<span className="text-textPlaceholder">{value}</span> <span className="text-textPlaceholder">{value}</span>
@@ -78,9 +78,11 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className="flex flex-row"> <div className="flex flex-row text-xs">
<span className="mr- text-textPlaceholder min-w-[140px]2">{key}:</span> <span className="text-textSubtle min-w-[140px]">{key}</span>
<pre className="whitespace-pre-wrap text-textPlaceholder">{content}</pre> <pre className="whitespace-pre-wrap text-textPlaceholder overflow-x-auto max-w-full">
{content}
</pre>
</div> </div>
</div> </div>
); );

View File

@@ -3,6 +3,7 @@ import { snakeToTitleCase } from '../utils';
import PermissionModal from './settings/permission/PermissionModal'; import PermissionModal from './settings/permission/PermissionModal';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { confirmPermission } from '../api'; import { confirmPermission } from '../api';
import { Button } from './ui/button';
const ALWAYS_ALLOW = 'always_allow'; const ALWAYS_ALLOW = 'always_allow';
const ALLOW_ONCE = 'allow_once'; const ALLOW_ONCE = 'allow_once';
@@ -58,16 +59,16 @@ export default function ToolConfirmation({
} }
return isCancelledMessage ? ( return isCancelledMessage ? (
<div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 text-textStandard"> <div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
Tool call confirmation is cancelled. Tool call confirmation is cancelled.
</div> </div>
) : ( ) : (
<> <>
<div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 rounded-b-none text-textStandard"> <div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 rounded-b-none text-textStandard">
Goose would like to call the above tool. Allow? Goose would like to call the above tool. Allow?
</div> </div>
{clicked ? ( {clicked ? (
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex items-center justify-between"> <div className="goose-message-tool bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
{status === 'always_allow' && ( {status === 'always_allow' && (
<svg <svg
@@ -118,31 +119,24 @@ export default function ToolConfirmation({
</div> </div>
</div> </div>
) : ( ) : (
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center"> <div className="goose-message-tool bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center">
<button <Button className="rounded-full" onClick={() => handleButtonClick(ALWAYS_ALLOW)}>
className={
'bg-black text-white dark:bg-white dark:text-black rounded-full px-6 py-2 transition'
}
onClick={() => handleButtonClick(ALWAYS_ALLOW)}
>
Always Allow Always Allow
</button> </Button>
<button <Button
className={ className="rounded-full"
'bg-bgProminent text-white dark:text-white rounded-full px-6 py-2 transition' variant="secondary"
}
onClick={() => handleButtonClick(ALLOW_ONCE)} onClick={() => handleButtonClick(ALLOW_ONCE)}
> >
Allow Once Allow Once
</button> </Button>
<button <Button
className={ className="rounded-full"
'bg-white text-black dark:bg-black dark:text-white border border-gray-300 dark:border-gray-700 rounded-full px-6 py-2 transition' variant="outline"
}
onClick={() => handleButtonClick(DENY)} onClick={() => handleButtonClick(DENY)}
> >
Deny Deny
</button> </Button>
</div> </div>
)} )}

View File

@@ -1,18 +1,19 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Card } from './ui/card'; import { Button } from './ui/button';
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments'; import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
import MarkdownContent from './MarkdownContent'; import MarkdownContent from './MarkdownContent';
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message'; import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
import { snakeToTitleCase } from '../utils'; import { cn, snakeToTitleCase } from '../utils';
import Dot, { LoadingStatus } from './ui/Dot'; import Dot, { LoadingStatus } from './ui/Dot';
import Expand from './ui/Expand';
import { NotificationEvent } from '../hooks/useMessageStream'; import { NotificationEvent } from '../hooks/useMessageStream';
import { ChevronRight, LoaderCircle } from 'lucide-react';
interface ToolCallWithResponseProps { interface ToolCallWithResponseProps {
isCancelledMessage: boolean; isCancelledMessage: boolean;
toolRequest: ToolRequestMessageContent; toolRequest: ToolRequestMessageContent;
toolResponse?: ToolResponseMessageContent; toolResponse?: ToolResponseMessageContent;
notifications?: NotificationEvent[]; notifications?: NotificationEvent[];
isStreamingMessage?: boolean;
} }
export default function ToolCallWithResponse({ export default function ToolCallWithResponse({
@@ -20,6 +21,7 @@ export default function ToolCallWithResponse({
toolRequest, toolRequest,
toolResponse, toolResponse,
notifications, notifications,
isStreamingMessage = false,
}: ToolCallWithResponseProps) { }: ToolCallWithResponseProps) {
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null; const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
if (!toolCall) { if (!toolCall) {
@@ -27,10 +29,14 @@ export default function ToolCallWithResponse({
} }
return ( return (
<div className={'w-full text-textSubtle text-sm'}> <div
<Card className=""> className={cn(
<ToolCallView {...{ isCancelledMessage, toolCall, toolResponse, notifications }} /> 'w-full text-sm rounded-lg overflow-hidden border-borderSubtle border bg-background-muted'
</Card> )}
>
<ToolCallView
{...{ isCancelledMessage, toolCall, toolResponse, notifications, isStreamingMessage }}
/>
</div> </div>
); );
} }
@@ -59,10 +65,19 @@ function ToolCallExpandable({
return ( return (
<div className={className}> <div className={className}>
<button onClick={toggleExpand} className="w-full flex justify-between items-center pr-2"> <Button
<span className="flex items-center">{label}</span> onClick={toggleExpand}
<Expand size={5} isExpanded={isExpanded} /> className="group w-full flex justify-between items-center pr-2 transition-colors rounded-none"
</button> variant="ghost"
>
<span className="flex items-center font-mono">{label}</span>
<ChevronRight
className={cn(
'group-hover:opacity-100 transition-transform opacity-70',
isExpanded && 'rotate-90'
)}
/>
</Button>
{isExpanded && <div>{children}</div>} {isExpanded && <div>{children}</div>}
</div> </div>
); );
@@ -76,6 +91,7 @@ interface ToolCallViewProps {
}; };
toolResponse?: ToolResponseMessageContent; toolResponse?: ToolResponseMessageContent;
notifications?: NotificationEvent[]; notifications?: NotificationEvent[];
isStreamingMessage?: boolean;
} }
interface Progress { interface Progress {
@@ -110,8 +126,28 @@ function ToolCallView({
toolCall, toolCall,
toolResponse, toolResponse,
notifications, notifications,
isStreamingMessage = false,
}: ToolCallViewProps) { }: ToolCallViewProps) {
const responseStyle = localStorage.getItem('response_style'); const [responseStyle, setResponseStyle] = useState(() => localStorage.getItem('response_style'));
// Listen for localStorage changes to update the response style
useEffect(() => {
const handleStorageChange = () => {
setResponseStyle(localStorage.getItem('response_style'));
};
// Listen for storage events (changes from other tabs/windows)
window.addEventListener('storage', handleStorageChange);
// Listen for custom events (changes from same tab)
window.addEventListener('responseStyleChanged', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('responseStyleChanged', handleStorageChange);
};
}, []);
const isExpandToolDetails = (() => { const isExpandToolDetails = (() => {
switch (responseStyle) { switch (responseStyle) {
case 'concise': case 'concise':
@@ -123,9 +159,27 @@ function ToolCallView({
})(); })();
const isToolDetails = Object.entries(toolCall?.arguments).length > 0; const isToolDetails = Object.entries(toolCall?.arguments).length > 0;
const loadingStatus: LoadingStatus = !toolResponse?.toolResult.status
? 'loading' // Check if streaming has finished but no tool response was received
: toolResponse?.toolResult.status; // This is a workaround for cases where the backend doesn't send tool responses
const isStreamingComplete = !isStreamingMessage;
const shouldShowAsComplete = isStreamingComplete && !toolResponse;
const loadingStatus: LoadingStatus = !toolResponse
? shouldShowAsComplete
? 'success'
: 'loading'
: toolResponse.toolResult.status;
// Tool call timing tracking
const [startTime, setStartTime] = useState<number | null>(null);
// Track when tool call starts (when there's no response yet)
useEffect(() => {
if (!toolResponse && startTime === null) {
setStartTime(Date.now());
}
}, [toolResponse, startTime]);
const toolResults: { result: Content; isExpandToolResults: boolean }[] = const toolResults: { result: Content; isExpandToolResults: boolean }[] =
loadingStatus === 'success' && Array.isArray(toolResponse?.toolResult.value) loadingStatus === 'success' && Array.isArray(toolResponse?.toolResult.value)
@@ -134,10 +188,17 @@ function ToolCallView({
const audience = item.annotations?.audience as string[] | undefined; const audience = item.annotations?.audience as string[] | undefined;
return !audience || audience.includes('user'); return !audience || audience.includes('user');
}) })
.map((item) => ({ .map((item) => {
// Use user preference for detailed/concise, but still respect high priority items
const priority = (item.annotations?.priority as number | undefined) ?? -1;
const isHighPriority = priority >= 0.5;
const shouldExpandBasedOnStyle = responseStyle === 'detailed' || responseStyle === null;
return {
result: item, result: item,
isExpandToolResults: ((item.annotations?.priority as number | undefined) ?? -1) >= 0.5, isExpandToolResults: isHighPriority || shouldExpandBasedOnStyle,
})) };
})
: []; : [];
const logs = notifications const logs = notifications
@@ -163,11 +224,19 @@ function ToolCallView({
const isRenderingProgress = const isRenderingProgress =
loadingStatus === 'loading' && (progressEntries.length > 0 || (logs || []).length > 0); loadingStatus === 'loading' && (progressEntries.length > 0 || (logs || []).length > 0);
// Only expand if there are actual results that need to be shown, not just for tool details // Determine if the main tool call should be expanded
const isShouldExpand = toolResults.some((v) => v.isExpandToolResults); const isShouldExpand = (() => {
// Always expand if there are high priority results that need to be shown
const hasHighPriorityResults = toolResults.some((v) => v.isExpandToolResults);
// Also expand based on user preference for detailed mode
const shouldExpandBasedOnStyle = responseStyle === 'detailed' || responseStyle === null;
return hasHighPriorityResults || shouldExpandBasedOnStyle;
})();
// Function to create a descriptive representation of what the tool is doing // Function to create a descriptive representation of what the tool is doing
const getToolDescription = () => { const getToolDescription = (): string | null => {
const args = toolCall.arguments as Record<string, ToolCallArgumentValue>; const args = toolCall.arguments as Record<string, ToolCallArgumentValue>;
const toolName = toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2); const toolName = toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2);
@@ -318,8 +387,14 @@ function ToolCallView({
isForceExpand={isShouldExpand} isForceExpand={isShouldExpand}
label={ label={
<> <>
<div className="w-2 flex items-center justify-center">
{loadingStatus === 'loading' ? (
<LoaderCircle className="animate-spin text-text-muted" size={3} />
) : (
<Dot size={2} loadingStatus={loadingStatus} /> <Dot size={2} loadingStatus={loadingStatus} />
<span className="ml-[10px]"> )}
</div>
<span className="ml-2">
{(() => { {(() => {
const description = getToolDescription(); const description = getToolDescription();
if (description) { if (description) {
@@ -334,17 +409,19 @@ function ToolCallView({
> >
{/* Tool Details */} {/* Tool Details */}
{isToolDetails && ( {isToolDetails && (
<div className="bg-bgStandard rounded-t mt-1"> <div className="border-t border-borderSubtle">
<ToolDetailsView toolCall={toolCall} isStartExpanded={isExpandToolDetails} /> <ToolDetailsView toolCall={toolCall} isStartExpanded={isExpandToolDetails} />
</div> </div>
)} )}
{logs && logs.length > 0 && ( {logs && logs.length > 0 && (
<div className="bg-bgStandard mt-1"> <div className="border-t border-borderSubtle">
<ToolLogsView <ToolLogsView
logs={logs} logs={logs}
working={toolResults.length === 0} working={loadingStatus === 'loading'}
isStartExpanded={toolResults.length === 0} isStartExpanded={
loadingStatus === 'loading' || responseStyle === 'detailed' || responseStyle === null
}
/> />
</div> </div>
)} )}
@@ -352,7 +429,7 @@ function ToolCallView({
{toolResults.length === 0 && {toolResults.length === 0 &&
progressEntries.length > 0 && progressEntries.length > 0 &&
progressEntries.map((entry, index) => ( progressEntries.map((entry, index) => (
<div className="p-2" key={index}> <div className="p-3 border-t border-borderSubtle" key={index}>
<ProgressBar progress={entry.progress} total={entry.total} message={entry.message} /> <ProgressBar progress={entry.progress} total={entry.total} message={entry.message} />
</div> </div>
))} ))}
@@ -361,15 +438,8 @@ function ToolCallView({
{!isCancelledMessage && ( {!isCancelledMessage && (
<> <>
{toolResults.map(({ result, isExpandToolResults }, index) => { {toolResults.map(({ result, isExpandToolResults }, index) => {
const isLast = index === toolResults.length - 1;
return ( return (
<div <div key={index} className={cn('border-t border-borderSubtle')}>
key={index}
className={`bg-bgStandard mt-1
${isToolDetails || index > 0 ? '' : 'rounded-t'}
${isLast ? 'rounded-b' : ''}
`}
>
<ToolResultView result={result} isStartExpanded={isExpandToolResults} /> <ToolResultView result={result} isStartExpanded={isExpandToolResults} />
</div> </div>
); );
@@ -391,13 +461,14 @@ interface ToolDetailsViewProps {
function ToolDetailsView({ toolCall, isStartExpanded }: ToolDetailsViewProps) { function ToolDetailsView({ toolCall, isStartExpanded }: ToolDetailsViewProps) {
return ( return (
<ToolCallExpandable <ToolCallExpandable
label="Tool Details" label={<span className="pl-4 font-medium">Tool Details</span>}
className="pl-[19px] py-1"
isStartExpanded={isStartExpanded} isStartExpanded={isStartExpanded}
> >
<div className="pr-4 pl-8">
{toolCall.arguments && ( {toolCall.arguments && (
<ToolCallArguments args={toolCall.arguments as Record<string, ToolCallArgumentValue>} /> <ToolCallArguments args={toolCall.arguments as Record<string, ToolCallArgumentValue>} />
)} )}
</div>
</ToolCallExpandable> </ToolCallExpandable>
); );
} }
@@ -410,14 +481,14 @@ interface ToolResultViewProps {
function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
return ( return (
<ToolCallExpandable <ToolCallExpandable
label={<span className="pl-[19px] py-1">Output</span>} label={<span className="pl-4 py-1 font-medium">Output</span>}
isStartExpanded={isStartExpanded} isStartExpanded={isStartExpanded}
> >
<div className="bg-bgApp rounded-b pl-[19px] pr-2 py-4"> <div className="pl-4 pr-4 py-4">
{result.type === 'text' && result.text && ( {result.type === 'text' && result.text && (
<MarkdownContent <MarkdownContent
content={result.text} content={result.text}
className="whitespace-pre-wrap p-2 max-w-full overflow-x-auto" className="whitespace-pre-wrap max-w-full overflow-x-auto"
/> />
)} )}
{result.type === 'image' && ( {result.type === 'image' && (
@@ -457,7 +528,7 @@ function ToolLogsView({
return ( return (
<ToolCallExpandable <ToolCallExpandable
label={ label={
<span className="pl-[19px] py-1"> <span className="pl-4 py-1 font-medium flex items-center">
<span>Logs</span> <span>Logs</span>
{working && ( {working && (
<div className="mx-2 inline-block"> <div className="mx-2 inline-block">
@@ -475,7 +546,7 @@ function ToolLogsView({
> >
<div <div
ref={boxRef} ref={boxRef}
className={`flex flex-col items-start space-y-2 overflow-y-auto ${working ? 'max-h-[4rem]' : 'max-h-[20rem]'} bg-bgApp`} className={`flex flex-col items-start space-y-2 overflow-y-auto p-4 ${working ? 'max-h-[4rem]' : 'max-h-[20rem]'}`}
> >
{logs.map((log, i) => ( {logs.map((log, i) => (
<span key={i} className="font-mono text-sm text-textSubtle"> <span key={i} className="font-mono text-sm text-textSubtle">
@@ -493,16 +564,16 @@ const ProgressBar = ({ progress, total, message }: Omit<Progress, 'progressToken
return ( return (
<div className="w-full space-y-2"> <div className="w-full space-y-2">
{message && <div className="text-sm text-gray-700">{message}</div>} {message && <div className="text-sm text-textSubtle">{message}</div>}
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden relative"> <div className="w-full bg-background-subtle rounded-full h-4 overflow-hidden relative">
{isDeterminate ? ( {isDeterminate ? (
<div <div
className="bg-blue-500 h-full transition-all duration-300" className="bg-primary h-full transition-all duration-300"
style={{ width: `${percent}%` }} style={{ width: `${percent}%` }}
/> />
) : ( ) : (
<div className="absolute inset-0 animate-indeterminate bg-blue-500" /> <div className="absolute inset-0 animate-indeterminate bg-primary" />
)} )}
</div> </div>
</div> </div>

View File

@@ -31,14 +31,14 @@ export default function UserMessage({ message }: UserMessageProps) {
const urls = extractUrls(displayText, []); const urls = extractUrls(displayText, []);
return ( return (
<div className="flex justify-end mt-[16px] w-full opacity-0 animate-[appear_150ms_ease-in_forwards]"> <div className="message flex justify-end mt-[16px] w-full opacity-0 animate-[appear_150ms_ease-in_forwards]">
<div className="flex-col max-w-[85%]"> <div className="flex-col max-w-[85%] w-fit">
<div className="flex flex-col group"> <div className="flex flex-col group">
<div className="flex bg-slate text-white rounded-xl rounded-br-none py-2 px-3"> <div className="flex bg-background-accent text-text-on-accent rounded-xl py-2.5 px-4">
<div ref={contentRef}> <div ref={contentRef}>
<MarkdownContent <MarkdownContent
content={displayText} content={displayText}
className="text-white prose-a:text-white prose-headings:text-white prose-strong:text-white prose-em:text-white user-message" className="text-text-on-accent prose-a:text-text-on-accent prose-headings:text-text-on-accent prose-strong:text-text-on-accent prose-em:text-text-on-accent user-message"
/> />
</div> </div>
</div> </div>
@@ -52,8 +52,8 @@ export default function UserMessage({ message }: UserMessageProps) {
</div> </div>
)} )}
<div className="relative h-[22px] flex justify-end"> <div className="relative h-[22px] flex justify-end text-right">
<div className="absolute right-0 text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0"> <div className="absolute w-40 font-mono right-0 text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp} {timestamp}
</div> </div>
<div className="absolute right-0 pt-1"> <div className="absolute right-0 pt-1">

View File

@@ -0,0 +1,600 @@
import { useState, useEffect } from 'react';
import { Recipe } from '../recipe';
import { Parameter } from '../recipe/index';
import { Buffer } from 'buffer';
import { FullExtensionConfig } from '../extensions';
import { Geese } from './icons/Geese';
import Copy from './icons/Copy';
import { Check, Save, Calendar, X } from 'lucide-react';
import { useConfig } from './ConfigContext';
import { FixedExtensionEntry } from './ConfigContext';
import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo';
import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
import ParameterInput from './parameter/ParameterInput';
import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
import { toastSuccess, toastError } from '../toasts';
import { Button } from './ui/button';
interface ViewRecipeModalProps {
isOpen: boolean;
onClose: () => void;
config: Recipe;
}
// Function to generate a deep link from a recipe
function generateDeepLink(recipe: Recipe): string {
const configBase64 = Buffer.from(JSON.stringify(recipe)).toString('base64');
const urlSafe = encodeURIComponent(configBase64);
return `goose://recipe?config=${urlSafe}`;
}
export default function ViewRecipeModal({ isOpen, onClose, config }: ViewRecipeModalProps) {
const { getExtensions } = useConfig();
const [recipeConfig] = useState<Recipe | undefined>(config);
const [title, setTitle] = useState(config?.title || '');
const [description, setDescription] = useState(config?.description || '');
const [instructions, setInstructions] = useState(config?.instructions || '');
const [prompt, setPrompt] = useState(config?.prompt || '');
const [activities, setActivities] = useState<string[]>(config?.activities || []);
const [parameters, setParameters] = useState<Parameter[]>(
parseParametersFromInstructions(instructions)
);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saveRecipeName, setSaveRecipeName] = useState('');
const [saveGlobal, setSaveGlobal] = useState(true);
const [saving, setSaving] = useState(false);
const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{
label: string;
value: string;
setValue: (value: string) => void;
} | null>(null);
// Initialize selected extensions for the recipe from config
const [recipeExtensions] = useState<string[]>(() => {
if (config?.extensions) {
return config.extensions.map((ext) => ext.name);
}
return [];
});
// Reset form when config changes
useEffect(() => {
if (config) {
setTitle(config.title || '');
setDescription(config.description || '');
setInstructions(config.instructions || '');
setPrompt(config.prompt || '');
setActivities(config.activities || []);
setParameters(parseParametersFromInstructions(config.instructions || ''));
}
}, [config]);
// Load extensions when modal opens
useEffect(() => {
if (isOpen && !extensionsLoaded) {
const loadExtensions = async () => {
try {
const extensions = await getExtensions(false);
console.log('Loading extensions for recipe modal');
if (extensions && extensions.length > 0) {
const initializedExtensions = extensions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(initializedExtensions);
setExtensionsLoaded(true);
}
} catch (error) {
console.error('Failed to load extensions:', error);
}
};
loadExtensions();
}
}, [isOpen, getExtensions, recipeExtensions, extensionsLoaded]);
// Use effect to set parameters whenever instructions or prompt changes
useEffect(() => {
const instructionsParams = parseParametersFromInstructions(instructions);
const promptParams = parseParametersFromInstructions(prompt);
// Combine parameters, ensuring no duplicates by key
const allParams = [...instructionsParams];
promptParams.forEach((promptParam) => {
if (!allParams.some((param) => param.key === promptParam.key)) {
allParams.push(promptParam);
}
});
setParameters(allParams);
}, [instructions, prompt]);
const getCurrentConfig = (): Recipe => {
// Transform the internal parameters state into the desired output format.
const formattedParameters = parameters.map((param) => {
const formattedParam: Parameter = {
key: param.key,
input_type: 'string',
requirement: param.requirement,
description: param.description,
};
// Add the 'default' key ONLY if the parameter is optional and has a default value.
if (param.requirement === 'optional' && param.default) {
formattedParam.default = param.default;
}
return formattedParam;
});
const updatedConfig = {
...recipeConfig,
title,
description,
instructions,
activities,
prompt,
parameters: formattedParameters,
extensions: recipeExtensions
.map((name) => {
const extension = extensionOptions.find((e) => e.name === name);
if (!extension) return null;
// Create a clean copy of the extension configuration
const { enabled: _enabled, ...cleanExtension } = extension;
// Remove legacy envs which could potentially include secrets
if ('envs' in cleanExtension) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { envs: _envs, ...finalExtension } = cleanExtension as any;
return finalExtension;
}
return cleanExtension;
})
.filter(Boolean) as FullExtensionConfig[],
};
return updatedConfig;
};
const [errors, setErrors] = useState<{
title?: string;
description?: string;
instructions?: string;
}>({});
const requiredFieldsAreFilled = () => {
return title.trim() && description.trim() && instructions.trim();
};
const validateForm = () => {
const newErrors: { title?: string; description?: string; instructions?: string } = {};
if (!title.trim()) {
newErrors.title = 'Title is required';
}
if (!description.trim()) {
newErrors.description = 'Description is required';
}
if (!instructions.trim()) {
newErrors.instructions = 'Instructions are required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleParameterChange = (name: string, value: Partial<Parameter>) => {
setParameters((prev) =>
prev.map((param) => (param.key === name ? { ...param, ...value } : param))
);
};
const deeplink = generateDeepLink(getCurrentConfig());
const handleCopy = () => {
navigator.clipboard
.writeText(deeplink)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch((err) => {
console.error('Failed to copy the text:', err);
});
};
const handleSaveRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
const currentRecipe = getCurrentConfig();
if (!currentRecipe.title || !currentRecipe.description || !currentRecipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(currentRecipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
// Reset dialog state
setShowSaveDialog(false);
setSaveRecipeName('');
toastSuccess({
title: saveRecipeName.trim(),
msg: `Recipe saved successfully`,
});
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleSaveRecipeClick = () => {
if (!validateForm()) {
return;
}
const currentRecipe = getCurrentConfig();
// Generate a suggested name from the recipe title
const suggestedName = generateRecipeFilename(currentRecipe);
setSaveRecipeName(suggestedName);
setShowSaveDialog(true);
};
const onClickEditTextArea = ({
label,
value,
setValue,
}: {
label: string;
value: string;
setValue: (value: string) => void;
}) => {
setRecipeInfoModalOpen(true);
setRecipeInfoModelProps({
label,
value,
setValue,
});
};
function parseParametersFromInstructions(instructions: string): Parameter[] {
const regex = /\{\{(.*?)\}\}/g;
const matches = [...instructions.matchAll(regex)];
return matches.map((match) => {
return {
key: match[1].trim(),
description: `Enter value for ${match[1].trim()}`,
requirement: 'required',
input_type: 'string',
};
});
}
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[400] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg w-[90vw] max-w-4xl h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-borderSubtle">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-background-default rounded-full flex items-center justify-center">
<Geese className="w-6 h-6 text-iconProminent" />
</div>
<div>
<h1 className="text-xl font-medium text-textProminent">View/edit current recipe</h1>
<p className="text-textSubtle text-sm">
You can edit the recipe below to change the agent's behavior in a new session.
</p>
</div>
</div>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="p-2 hover:bg-bgSubtle rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-6">
<div className="pb-6 border-b border-borderSubtle">
<label htmlFor="title" className="block text-md text-textProminent mb-2 font-bold">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (errors.title) {
setErrors({ ...errors, title: undefined });
}
}}
className={`w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
errors.title ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Agent Recipe Title (required)"
/>
{errors.title && <div className="text-red-500 text-sm mt-1">{errors.title}</div>}
</div>
<div className="pb-6 border-b border-borderSubtle">
<label
htmlFor="description"
className="block text-md text-textProminent mb-2 font-bold"
>
Description <span className="text-red-500">*</span>
</label>
<input
type="text"
value={description}
onChange={(e) => {
setDescription(e.target.value);
if (errors.description) {
setErrors({ ...errors, description: undefined });
}
}}
className={`w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
errors.description ? 'border-red-500' : 'border-borderSubtle'
}`}
placeholder="Description (required)"
/>
{errors.description && (
<div className="text-red-500 text-sm mt-1">{errors.description}</div>
)}
</div>
<div className="pb-6 border-b border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Instructions"
infoValue={instructions}
required={true}
onClickEdit={() =>
onClickEditTextArea({
label: 'Instructions',
value: instructions,
setValue: setInstructions,
})
}
/>
{errors.instructions && (
<div className="text-red-500 text-sm mt-1">{errors.instructions}</div>
)}
</div>
{parameters.map((parameter: Parameter) => (
<ParameterInput
key={parameter.key}
parameter={parameter}
onChange={(name, value) => handleParameterChange(name, value)}
/>
))}
<div className="pb-6 border-b border-borderSubtle">
<RecipeExpandableInfo
infoLabel="Initial Prompt"
infoValue={prompt}
required={false}
onClickEdit={() =>
onClickEditTextArea({
label: 'Initial Prompt',
value: prompt,
setValue: setPrompt,
})
}
/>
</div>
<div className="pb-6 border-b border-borderSubtle">
<RecipeActivityEditor activities={activities} setActivities={setActivities} />
</div>
{/* Deep Link Display */}
<div className="w-full p-4 bg-bgSubtle rounded-lg">
{!requiredFieldsAreFilled() ? (
<div className="text-sm text-textSubtle">
Fill in required fields to generate link
</div>
) : (
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-textSubtle">
Copy this link to share with friends or paste directly in Chrome to open
</div>
<Button
onClick={() => validateForm() && handleCopy()}
variant="ghost"
size="sm"
className="ml-4 p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-iconSubtle" />
)}
<span className="ml-1 text-sm text-textSubtle">
{copied ? 'Copied!' : 'Copy'}
</span>
</Button>
</div>
)}
{requiredFieldsAreFilled() && (
<div
onClick={() => validateForm() && handleCopy()}
className={`text-sm truncate font-mono cursor-pointer ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
>
{deeplink}
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-borderSubtle">
<Button
onClick={onClose}
variant="ghost"
className="px-4 py-2 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
>
Close
</Button>
<div className="flex gap-3">
<button
onClick={handleSaveRecipeClick}
disabled={!requiredFieldsAreFilled() || saving}
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Recipe'}
</button>
<Button
onClick={() => setIsScheduleModalOpen(true)}
disabled={!requiredFieldsAreFilled()}
variant="outline"
size="default"
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Calendar className="w-4 h-4" />
Create Schedule
</Button>
</div>
</div>
</div>
<RecipeInfoModal
infoLabel={recipeInfoModelProps?.label}
originalValue={recipeInfoModelProps?.value}
isOpen={isRecipeInfoModalOpen}
onClose={() => setRecipeInfoModalOpen(false)}
onSaveValue={recipeInfoModelProps?.setValue}
/>
<ScheduleFromRecipeModal
isOpen={isScheduleModalOpen}
onClose={() => setIsScheduleModalOpen(false)}
recipe={getCurrentConfig()}
onCreateSchedule={(deepLink) => {
// Open the schedules view with the deep link pre-filled
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
undefined,
'schedules'
);
// Store the deep link in localStorage for the schedules view to pick up
localStorage.setItem('pendingScheduleDeepLink', deepLink);
}}
/>
{/* Save Recipe Dialog */}
{showSaveDialog && (
<div className="fixed inset-0 z-[500] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-textStandard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)}
className="w-full p-3 border border-borderSubtle rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Enter recipe name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => {
setShowSaveDialog(false);
setSaveRecipeName('');
}}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Recipe'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,269 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { AlertType, useAlerts } from '../alerts';
import { useToolCount } from '../alerts/useToolCount';
import BottomMenuAlertPopover from './BottomMenuAlertPopover';
import type { View, ViewOptions } from '../../App';
import { BottomMenuModeSelection } from './BottomMenuModeSelection';
import ModelsBottomBar from '../settings/models/bottom_bar/ModelsBottomBar';
import { useConfig } from '../ConfigContext';
import { useModelAndProvider } from '../ModelAndProviderContext';
import { Message } from '../../types/message';
import { ManualSummarizeButton } from '../context_management/ManualSummaryButton';
import { CostTracker } from './CostTracker';
import { COST_TRACKING_ENABLED } from '../../updates';
const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about
const TOKEN_WARNING_THRESHOLD = 0.8; // warning shows at 80% of the token limit
const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning
interface ModelLimit {
pattern: string;
context_limit: number;
}
export default function BottomMenu({
setView,
numTokens = 0,
inputTokens = 0,
outputTokens = 0,
messages = [],
isLoading = false,
setMessages,
sessionCosts,
}: {
setView: (view: View, viewOptions?: ViewOptions) => void;
numTokens?: number;
inputTokens?: number;
outputTokens?: number;
messages?: Message[];
isLoading?: boolean;
setMessages: (messages: Message[]) => void;
sessionCosts?: {
[key: string]: {
inputTokens: number;
outputTokens: number;
totalCost: number;
};
};
}) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const { alerts, addAlert, clearAlerts } = useAlerts();
const dropdownRef = useRef<HTMLDivElement>(null);
const toolCount = useToolCount();
const { getProviders, read } = useConfig();
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
// Load model limits from the API
const getModelLimits = async () => {
try {
const response = await read('model-limits', false);
if (response) {
// The response is already parsed, no need for JSON.parse
return response as ModelLimit[];
}
} catch (err) {
console.error('Error fetching model limits:', err);
}
return [];
};
// Helper function to find model limit using pattern matching
const findModelLimit = (modelName: string, modelLimits: ModelLimit[]): number | null => {
if (!modelName) return null;
const matchingLimit = modelLimits.find((limit) =>
modelName.toLowerCase().includes(limit.pattern.toLowerCase())
);
return matchingLimit ? matchingLimit.context_limit : null;
};
// Load providers and get current model's token limit
const loadProviderDetails = async () => {
try {
// Reset token limit loaded state
setIsTokenLimitLoaded(false);
// Get current model and provider first to avoid unnecessary provider fetches
const { model, provider } = await getCurrentModelAndProvider();
if (!model || !provider) {
console.log('No model or provider found');
setIsTokenLimitLoaded(true);
return;
}
const providers = await getProviders(true);
// Find the provider details for the current provider
const currentProvider = providers.find((p) => p.name === provider);
if (currentProvider?.metadata?.known_models) {
// Find the model's token limit from the backend response
const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model);
if (modelConfig?.context_limit) {
setTokenLimit(modelConfig.context_limit);
setIsTokenLimitLoaded(true);
return;
}
}
// Fallback: Use pattern matching logic if no exact model match was found
const modelLimit = await getModelLimits();
const fallbackLimit = findModelLimit(model as string, modelLimit);
if (fallbackLimit !== null) {
setTokenLimit(fallbackLimit);
setIsTokenLimitLoaded(true);
return;
}
// If no match found, use the default model limit
setTokenLimit(TOKEN_LIMIT_DEFAULT);
setIsTokenLimitLoaded(true);
} catch (err) {
console.error('Error loading providers or token limit:', err);
// Set default limit on error
setTokenLimit(TOKEN_LIMIT_DEFAULT);
setIsTokenLimitLoaded(true);
}
};
// Initial load and refresh when model changes
useEffect(() => {
loadProviderDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentModel, currentProvider]);
// Handle tool count alerts and token usage
useEffect(() => {
clearAlerts();
// Only show token alerts if we have loaded the real token limit
if (isTokenLimitLoaded && tokenLimit && numTokens > 0) {
if (numTokens >= tokenLimit) {
// Only show error alert when limit reached
addAlert({
type: AlertType.Error,
message: `Token limit reached (${numTokens.toLocaleString()}/${tokenLimit.toLocaleString()}) \n You've reached the model's conversation limit. The session will be saved — copy anything important and start a new one to continue.`,
autoShow: true, // Auto-show token limit errors
});
} else if (numTokens >= tokenLimit * TOKEN_WARNING_THRESHOLD) {
// Only show warning alert when approaching limit
addAlert({
type: AlertType.Warning,
message: `Approaching token limit (${numTokens.toLocaleString()}/${tokenLimit.toLocaleString()}) \n You're reaching the model's conversation limit. The session will be saved — copy anything important and start a new one to continue.`,
autoShow: true, // Auto-show token limit warnings
});
} else {
// Show info alert only when not in warning/error state
addAlert({
type: AlertType.Info,
message: 'Context window',
progress: {
current: numTokens,
total: tokenLimit,
},
});
}
}
// Add tool count alert if we have the data
if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) {
addAlert({
type: AlertType.Warning,
message: `Too many tools can degrade performance.\nTool count: ${toolCount} (recommend: ${TOOLS_MAX_SUGGESTED})`,
action: {
text: 'View extensions',
onClick: () => setView('settings'),
},
autoShow: false, // Don't auto-show tool count warnings
});
}
// We intentionally omit setView as it shouldn't trigger a re-render of alerts
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]);
// Add effect to handle clicks outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsModelMenuOpen(false);
}
};
if (isModelMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isModelMenuOpen]);
// Add effect to handle Escape key
useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsModelMenuOpen(false);
}
};
if (isModelMenuOpen) {
window.addEventListener('keydown', handleEsc);
}
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [isModelMenuOpen]);
return (
<div className="flex justify-between items-center transition-colors text-textSubtle relative text-xs h-6">
<div className="flex items-center h-full">
{/* Tool and Token count */}
<div className="flex items-center h-full pl-2">
{<BottomMenuAlertPopover alerts={alerts} />}
</div>
{/* Cost Tracker - no separator before it */}
{COST_TRACKING_ENABLED && (
<>
<div className="flex items-center h-full ml-1">
<CostTracker
inputTokens={inputTokens}
outputTokens={outputTokens}
sessionCosts={sessionCosts}
/>
</div>
<div className="w-[1px] h-4 bg-borderSubtle mx-1.5" />
</>
)}
{/* Model Selector Dropdown */}
<div className="flex items-center h-full">
<ModelsBottomBar dropdownRef={dropdownRef} setView={setView} />
</div>
{/* Separator */}
<div className="w-[1px] h-4 bg-borderSubtle mx-1.5" />
{/* Goose Mode Selector Dropdown */}
<div className="flex items-center h-full">
<BottomMenuModeSelection setView={setView} />
</div>
{/* Summarize Context Button */}
{messages.length > 0 && (
<>
<div className="w-[1px] h-4 bg-borderSubtle mx-1.5" />
<div className="flex items-center h-full">
<ManualSummarizeButton
messages={messages}
isLoading={isLoading}
setMessages={setMessages}
/>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import React, { useRef, useEffect, useCallback } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import { FaCircle } from 'react-icons/fa'; import { FaCircle } from 'react-icons/fa';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { cn } from '../../utils'; import { cn } from '../../utils';
import { Alert, AlertType } from '../alerts'; import { Alert, AlertType } from '../alerts';
import { AlertBox } from '../alerts'; import { AlertBox } from '../alerts';
@@ -12,14 +11,73 @@ interface AlertPopoverProps {
} }
export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) { export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = useState(false);
const [hasShownInitial, setHasShownInitial] = React.useState(false); const [hasShownInitial, setHasShownInitial] = useState(false);
const [isHovered, setIsHovered] = React.useState(false); const [isHovered, setIsHovered] = useState(false);
const [wasAutoShown, setWasAutoShown] = React.useState(false); const [wasAutoShown, setWasAutoShown] = useState(false);
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
const previousAlertsRef = useRef<Alert[]>([]); const previousAlertsRef = useRef<Alert[]>([]);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>(); const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
// Calculate popover position
const calculatePosition = useCallback(() => {
if (!triggerRef.current || !popoverRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const popoverWidth = 275;
// Get the actual rendered height of the popover
const popoverHeight = popoverRef.current.offsetHeight || 120;
const offset = 8; // Small gap to avoid blocking the trigger dot
// Position above the trigger, centered horizontally
let top = triggerRect.top - popoverHeight - offset;
let left = triggerRect.left + triggerRect.width / 2 - popoverWidth / 2;
// Ensure popover doesn't go off-screen
const viewportWidth = window.innerWidth;
// Adjust horizontal position if off-screen
if (left < 10) {
left = 10;
} else if (left + popoverWidth > viewportWidth - 10) {
left = viewportWidth - popoverWidth - 10;
}
// If popover would go above viewport, show it below the trigger instead
if (top < 10) {
top = triggerRect.bottom + offset;
}
setPopoverPosition({ top, left });
}, []);
// Update position when popover opens
useEffect(() => {
if (isOpen) {
calculatePosition();
// Recalculate on window resize
const handleResize = () => calculatePosition();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}
return undefined;
}, [isOpen, calculatePosition]);
// Recalculate position after popover is rendered to get actual height
useEffect(() => {
if (isOpen && popoverRef.current) {
// Small delay to ensure DOM is updated
const timer = setTimeout(() => {
calculatePosition();
}, 10);
return () => clearTimeout(timer);
}
return undefined;
}, [isOpen, calculatePosition]);
// Function to start the hide timer // Function to start the hide timer
const startHideTimer = useCallback((duration = 3000) => { const startHideTimer = useCallback((duration = 3000) => {
// Clear any existing timer // Clear any existing timer
@@ -98,12 +156,14 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
: 'text-[#cc4b03]'; // Orange color for warning alerts : 'text-[#cc4b03]'; // Orange color for warning alerts
return ( return (
<div ref={popoverRef}> <>
<Popover open={isOpen}>
<div className="relative"> <div className="relative">
<PopoverTrigger asChild> <button
<div ref={triggerRef}
className="cursor-pointer flex items-center justify-center min-w-5 min-h-5 translate-y-[1px]" className="cursor-pointer flex items-center justify-center min-w-5 min-h-5 rounded hover:bg-background-muted"
onClick={() => {
setIsOpen(true);
}}
onMouseEnter={() => { onMouseEnter={() => {
setIsOpen(true); setIsOpen(true);
setIsHovered(true); setIsHovered(true);
@@ -122,33 +182,22 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
}, 100); }, 100);
}} }}
> >
<div className={cn('relative', '-right-1', triggerColor)}> <div className={cn('relative', triggerColor)}>
<FaCircle size={5} /> <FaCircle size={5} />
</div> </div>
</button>
</div> </div>
</PopoverTrigger>
{/* Small connector area between trigger and content */} {/* Popover rendered separately to avoid blocking clicks */}
{isOpen && ( {isOpen && (
<div <div
className="absolute -right-2 h-6 w-8 top-full" ref={popoverRef}
onMouseEnter={() => { className="fixed w-[275px] p-0 rounded-lg overflow-hidden bg-app border z-50 shadow-lg pointer-events-auto text-left"
setIsHovered(true); style={{
if (hideTimerRef.current) { top: `${popoverPosition.top}px`,
clearTimeout(hideTimerRef.current); left: `${popoverPosition.left}px`,
} visibility: popoverPosition.top === 0 ? 'hidden' : 'visible',
}} }}
onMouseLeave={() => {
setIsHovered(false);
}}
/>
)}
<PopoverContent
className="w-[275px] p-0 rounded-lg overflow-hidden"
align="end"
alignOffset={-100}
sideOffset={5}
onMouseEnter={() => { onMouseEnter={() => {
setIsHovered(true); setIsHovered(true);
if (hideTimerRef.current) { if (hideTimerRef.current) {
@@ -167,9 +216,8 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
</div> </div>
))} ))}
</div> </div>
</PopoverContent>
</div>
</Popover>
</div> </div>
)}
</>
); );
} }

View File

@@ -1,17 +1,16 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useCallback, useState } from 'react';
import { Tornado } from 'lucide-react';
import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem'; import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem';
import { useConfig } from '../ConfigContext'; import { useConfig } from '../ConfigContext';
import { View, ViewOptions } from '../../App'; import {
import { Orbit } from 'lucide-react'; DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
interface BottomMenuModeSelectionProps { export const BottomMenuModeSelection = () => {
setView: (view: View, viewOptions?: ViewOptions) => void;
}
export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProps) => {
const [isGooseModeMenuOpen, setIsGooseModeMenuOpen] = useState(false);
const [gooseMode, setGooseMode] = useState('auto'); const [gooseMode, setGooseMode] = useState('auto');
const gooseModeDropdownRef = useRef<HTMLDivElement>(null);
const { read, upsert } = useConfig(); const { read, upsert } = useConfig();
const fetchCurrentMode = useCallback(async () => { const fetchCurrentMode = useCallback(async () => {
@@ -29,41 +28,6 @@ export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProp
fetchCurrentMode(); fetchCurrentMode();
}, [fetchCurrentMode]); }, [fetchCurrentMode]);
useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsGooseModeMenuOpen(false);
}
};
if (isGooseModeMenuOpen) {
window.addEventListener('keydown', handleEsc);
}
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [isGooseModeMenuOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
gooseModeDropdownRef.current &&
!gooseModeDropdownRef.current.contains(event.target as Node)
) {
setIsGooseModeMenuOpen(false);
}
};
if (isGooseModeMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isGooseModeMenuOpen]);
const handleModeChange = async (newMode: string) => { const handleModeChange = async (newMode: string) => {
if (gooseMode === newMode) { if (gooseMode === newMode) {
return; return;
@@ -83,35 +47,34 @@ export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProp
return mode ? mode.label : 'auto'; return mode ? mode.label : 'auto';
} }
return ( function getModeDescription(key: string) {
<div className="relative flex items-center" ref={gooseModeDropdownRef}> const mode = all_goose_modes.find((mode) => mode.key === key);
<button return mode ? mode.description : 'Automatic mode selection';
className="flex items-center justify-center text-textSubtle hover:text-textStandard h-6 [&_svg]:size-4" }
onClick={() => setIsGooseModeMenuOpen(!isGooseModeMenuOpen)}
>
<span className="pr-1.5">{getValueByKey(gooseMode).toLowerCase()}</span>
<Orbit />
</button>
{/* Dropdown Menu */} return (
{isGooseModeMenuOpen && ( <div title={`Current mode: ${getValueByKey(gooseMode)} - ${getModeDescription(gooseMode)}`}>
<div className="absolute bottom-[24px] right-0 w-[240px] py-2 bg-bgApp rounded-lg border border-borderSubtle"> <DropdownMenu>
<div> <DropdownMenuTrigger asChild>
<span className="flex items-center cursor-pointer [&_svg]:size-4 text-text-default/70 hover:text-text-default hover:scale-100 hover:bg-transparent text-xs">
<Tornado className="mr-1 h-4 w-4" />
{getValueByKey(gooseMode).toLowerCase()}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" side="top" align="center">
{all_goose_modes.map((mode) => ( {all_goose_modes.map((mode) => (
<DropdownMenuItem key={mode.key} asChild>
<ModeSelectionItem <ModeSelectionItem
key={mode.key}
mode={mode} mode={mode}
currentMode={gooseMode} currentMode={gooseMode}
showDescription={false} showDescription={false}
isApproveModeConfigure={false} isApproveModeConfigure={false}
parentView="chat"
setView={setView}
handleModeChange={handleModeChange} handleModeChange={handleModeChange}
/> />
</DropdownMenuItem>
))} ))}
</div> </DropdownMenuContent>
</div> </DropdownMenu>
)}
</div> </div>
); );
}; };

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useModelAndProvider } from '../ModelAndProviderContext'; import { useModelAndProvider } from '../ModelAndProviderContext';
import { useConfig } from '../ConfigContext'; import { useConfig } from '../ConfigContext';
import { CoinIcon } from '../icons';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import { import {
getCostForModel, getCostForModel,
initializeCostDatabase, initializeCostDatabase,
@@ -170,8 +172,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
}; };
const formatCost = (cost: number): string => { const formatCost = (cost: number): string => {
// Always show 6 decimal places for consistency // Always show 4 decimal places for consistency
return cost.toFixed(6); return cost.toFixed(4);
}; };
// Show loading state or when we don't have model/provider info // Show loading state or when we don't have model/provider info
@@ -182,9 +184,12 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
// If still loading, show a placeholder // If still loading, show a placeholder
if (isLoading) { if (isLoading) {
return ( return (
<>
<div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px]"> <div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px]">
<span className="text-xs font-mono">...</span> <span className="text-xs font-mono">...</span>
</div> </div>
<div className="w-px h-4 bg-border-default mx-2" />
</>
); );
} }
@@ -197,12 +202,20 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
const freeProviders = ['ollama', 'local', 'localhost']; const freeProviders = ['ollama', 'local', 'localhost'];
if (freeProviders.includes(currentProvider.toLowerCase())) { if (freeProviders.includes(currentProvider.toLowerCase())) {
return ( return (
<div <>
className="flex items-center justify-center h-full text-textSubtle hover:text-textStandard transition-colors cursor-default translate-y-[1px]" <Tooltip>
title={`Local model (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`} <TooltipTrigger asChild>
> <div className="flex items-center justify-center h-full text-text-default/70 hover:text-text-default transition-colors cursor-default translate-y-[1px]">
<span className="text-xs font-mono">$0.000000</span> <CoinIcon className="mr-1" size={16} />
<span className="text-xs font-mono">0.0000</span>
</div> </div>
</TooltipTrigger>
<TooltipContent>
{`Local model (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`}
</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
</>
); );
} }
@@ -216,16 +229,18 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
}; };
return ( return (
<div <>
className={`flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] ${ <Tooltip>
(pricingFailed || modelNotFound) && hasAttemptedFetch && initialLoadComplete <TooltipTrigger asChild>
? 'text-red-500 hover:text-red-400' <div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
: 'text-textSubtle hover:text-textStandard' <CoinIcon className="mr-1" size={16} />
}`} <span className="text-xs font-mono">0.0000</span>
title={getUnavailableTooltip()}
>
<span className="text-xs font-mono">$0.000000</span>
</div> </div>
</TooltipTrigger>
<TooltipContent>{getUnavailableTooltip()}</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
</>
); );
} }
@@ -271,18 +286,17 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
}; };
return ( return (
<div <>
className={`flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] ${ <Tooltip>
(pricingFailed || modelNotFound) && hasAttemptedFetch && initialLoadComplete <TooltipTrigger asChild>
? 'text-red-500 hover:text-red-400' <div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
: 'text-textSubtle hover:text-textStandard' <CoinIcon className="mr-1" size={16} />
}`} <span className="text-xs font-mono">{formatCost(totalCost)}</span>
title={getTooltipContent()}
>
<span className="text-xs font-mono">
{costInfo.currency || '$'}
{formatCost(totalCost)}
</span>
</div> </div>
</TooltipTrigger>
<TooltipContent>{getTooltipContent()}</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
</>
); );
} }

View File

@@ -0,0 +1,44 @@
import React, { useState } from 'react';
import { FolderDot } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
interface DirSwitcherProps {
hasMessages?: boolean;
className?: string;
}
export const DirSwitcher: React.FC<DirSwitcherProps> = ({
hasMessages = false,
className = '',
}) => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const handleDirectoryChange = async () => {
if (hasMessages) {
window.electron.directoryChooser();
} else {
window.electron.directoryChooser(true);
}
};
return (
<TooltipProvider>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<button
className={`z-[100] hover:cursor-pointer text-text-default/70 hover:text-text-default text-xs flex items-center transition-colors pl-1 [&>svg]:size-4 ${className}`}
onClick={handleDirectoryChange}
>
<FolderDot className="mr-1" size={16} />
<div className="max-w-[200px] truncate [direction:rtl]">
{String(window.appConfig.get('GOOSE_WORKING_DIR'))}
</div>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-96 overflow-auto scrollbar-thin" side="top">
{window.appConfig.get('GOOSE_WORKING_DIR') as string}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,17 @@
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>
);
};

View File

@@ -0,0 +1,128 @@
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>
);
};

View File

@@ -0,0 +1,276 @@
import { useEffect, useState } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
import { getApiUrl, getSecretKey } from '../../config';
interface ActivityHeatmapCell {
week: number;
day: number;
count: number;
date?: string; // Add date for better display in tooltips
}
// Days of the week for labeling
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Number of weeks in a year
const WEEKS_IN_YEAR = 52;
export function ActivityHeatmap() {
const [heatmapData, setHeatmapData] = useState<ActivityHeatmapCell[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentYear] = useState(new Date().getFullYear());
// Calculate the intensity for coloring cells
const getColorIntensity = (count: number, maxCount: number) => {
if (count === 0) return 'bg-background-muted/30';
const normalizedCount = count / maxCount;
if (normalizedCount < 0.25) return 'bg-background-accent/20';
if (normalizedCount < 0.5) return 'bg-background-accent/40';
if (normalizedCount < 0.75) return 'bg-background-accent/60';
return 'bg-background-accent/80';
};
useEffect(() => {
const fetchHeatmapData = async () => {
try {
setLoading(true);
const response = await fetch(getApiUrl('/sessions/activity-heatmap'), {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
});
if (!response.ok) {
throw new Error(`Failed to fetch heatmap data: ${response.status}`);
}
const data = await response.json();
setHeatmapData(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load heatmap data');
} finally {
setLoading(false);
}
};
fetchHeatmapData();
}, []);
// Find the maximum count for scaling
const maxCount = Math.max(
1, // Avoid division by zero
...heatmapData.map((cell) => cell.count)
);
// Create a calendar grid from Jan 1st of current year to today
const prepareGridData = () => {
// Get current date
const now = new Date();
const startOfYear = new Date(currentYear, 0, 1); // Jan 1st of current year
// Calculate weeks to display - now showing full year (52 weeks)
// const weeksToDisplay = Math.ceil((daysSinceStartOfYear + getStartDayOfYear()) / 7);
const weeksToDisplay = WEEKS_IN_YEAR;
// Create a map to lookup counts easily
const dataMap = new Map<string, number>();
heatmapData.forEach((cell) => {
dataMap.set(`${cell.week}-${cell.day}`, cell.count);
});
// Build the grid
const grid = [];
// Fill grid with dates and activity data
for (let week = 0; week < weeksToDisplay; week++) {
const weekCells = [];
for (let day = 0; day < 7; day++) {
// Convert week and day to a real date
const cellDate = new Date(startOfYear);
cellDate.setDate(cellDate.getDate() + week * 7 + day - getStartDayOfYear());
// Only include dates up to today for real data
const isFuture = cellDate > now;
// Format the date string
const dateStr = cellDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
// Get count from data if available
let count = 0;
// Try to find a matching date in our data
// This requires matching the specific week number (from ISO week) and day
if (!isFuture) {
for (const cell of heatmapData) {
if (cell.week === getWeekNumber(cellDate) && cell.day === day) {
count = cell.count;
break;
}
}
}
weekCells.push({
week,
day,
count,
date: dateStr,
});
}
grid.push(weekCells);
}
return grid;
};
// Helper to get day of week (0-6) of Jan 1st for current year
const getStartDayOfYear = () => {
return new Date(currentYear, 0, 1).getDay();
};
// Get ISO week number for a date
const getWeekNumber = (date: Date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
const week1 = new Date(d.getFullYear(), 0, 4);
return (
1 +
Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
);
};
const grid = prepareGridData();
if (loading) {
return (
<div className="h-[120px] flex items-center justify-center">
<div className="text-text-muted">Loading activity data...</div>
</div>
);
}
if (error) {
return (
<div className="h-[120px] flex items-center justify-center">
<div className="text-red-500">Error loading activity data</div>
</div>
);
}
// Get month labels - now showing all months
const getMonthLabels = () => {
const allMonths = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return allMonths.map((month, i) => {
// Calculate position based on days in month and start day of year
const monthIndex = i;
const daysBeforeMonth = getDaysBeforeMonth(monthIndex);
const position = (daysBeforeMonth - getStartDayOfYear()) / 7;
return (
<div
key={month}
className="text-[10px] text-text-muted absolute"
style={{
left: `${(position / WEEKS_IN_YEAR) * 100}%`,
transform: 'translateX(-50%)',
}}
>
{month}
</div>
);
});
};
// Helper to calculate days before a month in current year
const getDaysBeforeMonth = (monthIndex: number) => {
const days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
// Adjust for leap year
if (monthIndex > 1 && isLeapYear(currentYear)) {
return days[monthIndex] + 1;
}
return days[monthIndex];
};
// Helper to check if year is a leap year
const isLeapYear = (year: number) => {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
};
return (
<div className="w-full px-4">
{/* Month labels */}
<div className="relative h-4 ml-12 mb-2 mr-4">{getMonthLabels()}</div>
<div className="flex w-full">
{/* Day labels - now right-aligned */}
<div className="flex flex-col pt-1 pr-2 w-10">
{DAYS.map((day, index) => (
<div
key={day}
className="h-3 text-[10px] text-text-muted flex items-center justify-end"
>
{index % 2 === 0 ? day : ''}
</div>
))}
</div>
{/* Grid - with smaller squares */}
<div className="flex gap-[1px] flex-1 mr-4">
{grid.map((week, weekIndex) => (
<div
key={weekIndex}
className="flex flex-col gap-[1px] flex-1"
style={{ maxWidth: `calc(100% / ${WEEKS_IN_YEAR})` }}
>
{week.map((cell) => (
<TooltipProvider key={`${cell.week}-${cell.day}`}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`aspect-square w-full h-2 rounded-[1px] ${getColorIntensity(cell.count, maxCount)}`}
role="gridcell"
/>
</TooltipTrigger>
<TooltipContent side="top">
{cell.date ? (
<p className="text-xs">
{cell.count} sessions on {cell.date}
</p>
) : (
<p className="text-xs">No data</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { useTextAnimator } from '../../hooks/use-text-animator';
interface GreetingProps {
className?: string;
forceRefresh?: boolean;
}
export function Greeting({
className = 'mt-1 text-4xl font-light animate-in fade-in duration-300',
forceRefresh = false,
}: GreetingProps) {
const prefixes = ['Hello!'];
const messages = [
' Ready to get started?',
' What would you like to work on?',
' Ready to build something amazing?',
' What would you like to explore?',
" What's on your mind?",
' What shall we create today?',
' What project needs attention?',
' What would you like to tackle?',
' What would you like to explore?',
' What needs to be done?',
" What's the plan for today?",
' Ready to create something great?',
' What can be built today?',
" What's the next challenge?",
' What progress can be made?',
' What would you like to accomplish?',
' What task awaits?',
" What's the mission today?",
' What can be achieved?',
' What project is ready to begin?',
];
// Using lazy initializer to generate random greeting on each component instance
const greeting = useState(() => {
const randomPrefixIndex = Math.floor(Math.random() * prefixes.length);
const randomMessageIndex = Math.floor(Math.random() * messages.length);
return {
prefix: prefixes[randomPrefixIndex],
message: messages[randomMessageIndex],
};
})[0];
const messageRef = useTextAnimator({ text: greeting.message });
return (
<h1 className={className} key={forceRefresh ? Date.now() : undefined}>
{/* <span>{greeting.prefix}</span> */}
<span ref={messageRef}>{greeting.message}</span>
</h1>
);
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Message } from '../../types/message'; import { Message } from '../../types/message';
import { useChatContextManager } from './ChatContextManager'; import { useChatContextManager } from './ChatContextManager';
import { Button } from '../ui/button';
interface ContextHandlerProps { interface ContextHandlerProps {
messages: Message[]; messages: Message[];
@@ -8,6 +9,7 @@ interface ContextHandlerProps {
chatId: string; chatId: string;
workingDir: string; workingDir: string;
contextType: 'contextLengthExceeded' | 'summarizationRequested'; contextType: 'contextLengthExceeded' | 'summarizationRequested';
onSummaryComplete?: () => void; // Add callback for when summary is complete
} }
export const ContextHandler: React.FC<ContextHandlerProps> = ({ export const ContextHandler: React.FC<ContextHandlerProps> = ({
@@ -16,6 +18,7 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
chatId, chatId,
workingDir, workingDir,
contextType, contextType,
onSummaryComplete,
}) => { }) => {
const { const {
summaryContent, summaryContent,
@@ -39,6 +42,33 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
// Use a ref to track if we've started the fetch // Use a ref to track if we've started the fetch
const fetchStartedRef = useRef(false); const fetchStartedRef = useRef(false);
const hasCalledSummaryComplete = useRef(false);
// Call onSummaryComplete when summary is ready
useEffect(() => {
if (summaryContent && shouldAllowSummaryInteraction && !hasCalledSummaryComplete.current) {
hasCalledSummaryComplete.current = true;
// Delay the scroll slightly to ensure the content is rendered
setTimeout(() => {
onSummaryComplete?.();
}, 100);
}
// Reset the flag when summary is cleared
if (!summaryContent) {
hasCalledSummaryComplete.current = false;
}
}, [summaryContent, shouldAllowSummaryInteraction, onSummaryComplete]);
// Scroll when summarization starts (loading state)
useEffect(() => {
if (isLoadingSummary && shouldAllowSummaryInteraction) {
// Delay the scroll slightly to ensure the loading content is rendered
setTimeout(() => {
onSummaryComplete?.();
}, 100);
}
}, [isLoadingSummary, shouldAllowSummaryInteraction, onSummaryComplete]);
// Function to trigger the async operation properly // Function to trigger the async operation properly
const triggerContextLengthExceeded = () => { const triggerContextLengthExceeded = () => {
@@ -122,12 +152,9 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
? `This conversation has too much information to continue. Extension data often takes up significant space.` ? `This conversation has too much information to continue. Extension data often takes up significant space.`
: `Summarization failed. Continue chatting or start a new session.`} : `Summarization failed. Continue chatting or start a new session.`}
</span> </span>
<button <Button onClick={openNewSession} className="text-xs transition-colors mt-1 flex items-center">
onClick={openNewSession}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
>
Click here to start a new session Click here to start a new session
</button> </Button>
</> </>
); );
@@ -138,12 +165,9 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
? `Your conversation has exceeded the model's context capacity` ? `Your conversation has exceeded the model's context capacity`
: `Summarization requested`} : `Summarization requested`}
</span> </span>
<button <Button onClick={handleRetry} className="text-xs transition-colors mt-1 flex items-center">
onClick={handleRetry}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
>
Retry loading summary Retry loading summary
</button> </Button>
</> </>
); );
@@ -160,15 +184,15 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
: `This summary includes key points from your conversation.`} : `This summary includes key points from your conversation.`}
</span> </span>
{shouldAllowSummaryInteraction && ( {shouldAllowSummaryInteraction && (
<button <Button
onClick={openSummaryModal} onClick={openSummaryModal}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center" className="text-xs transition-colors mt-1 flex items-center"
> >
View or edit summary{' '} View or edit summary{' '}
{isContextLengthExceeded {isContextLengthExceeded
? '(you may continue your conversation based on the summary)' ? '(you may continue your conversation based on the summary)'
: ''} : ''}
</button> </Button>
)} )}
</> </>
); );

View File

@@ -1,7 +1,16 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ScrollText } from 'lucide-react'; import { ScrollText } from 'lucide-react';
import Modal from '../Modal'; import { cn } from '../../utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import { useChatContextManager } from './ChatContextManager'; import { useChatContextManager } from './ChatContextManager';
import { Message } from '../../types/message'; import { Message } from '../../types/message';
@@ -34,64 +43,66 @@ export const ManualSummarizeButton: React.FC<ManualSummarizeButtonProps> = ({
} }
}; };
// Footer content for the confirmation modal const handleClose = () => {
const footerContent = ( setIsConfirmationOpen(false);
<> };
<Button
onClick={handleSummarize}
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-large"
>
Summarize
</Button>
<Button
onClick={() => setIsConfirmationOpen(false)}
variant="ghost"
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-large font-regular"
>
Cancel
</Button>
</>
);
return ( return (
<> <>
<div className="w-px h-4 bg-border-default mx-2" />
<div className="relative flex items-center"> <div className="relative flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<button <button
className={`flex items-center justify-center text-textSubtle hover:text-textStandard h-6 [&_svg]:size-4 ${ type="button"
isLoadingSummary || isLoading ? 'opacity-50 cursor-not-allowed' : '' className={cn(
}`} 'flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors',
(isLoadingSummary || isLoading) &&
'cursor-not-allowed text-text-default/30 hover:text-text-default/30 opacity-50'
)}
onClick={handleClick} onClick={handleClick}
disabled={isLoadingSummary || isLoading} disabled={isLoadingSummary || isLoading}
title="Summarize conversation context"
> >
<ScrollText size={16} /> <ScrollText size={16} />
</button> </button>
</TooltipTrigger>
<TooltipContent>
{isLoadingSummary ? 'Summarizing conversation...' : 'Summarize conversation context'}
</TooltipContent>
</Tooltip>
</div> </div>
{/* Confirmation Modal */} {/* Confirmation Modal */}
{isConfirmationOpen && ( <Dialog open={isConfirmationOpen} onOpenChange={handleClose}>
<Modal footer={footerContent} onClose={() => setIsConfirmationOpen(false)}> <DialogContent className="sm:max-w-[500px]">
<div className="flex flex-col mb-6"> <DialogHeader>
<div> <DialogTitle className="flex items-center gap-2">
<ScrollText className="text-iconStandard" size={24} /> <ScrollText className="text-iconStandard" size={24} />
</div> Summarize Conversation
<div className="mt-2"> </DialogTitle>
<h2 className="text-2xl font-regular text-textStandard">Summarize Conversation</h2> <DialogDescription>
</div>
</div>
<div className="mb-6">
<p className="text-textStandard mb-4">
This will summarize your conversation history to save context space. This will summarize your conversation history to save context space.
</p> </DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-textStandard"> <p className="text-textStandard">
Previous messages will remain visible but only the summary will be included in the Previous messages will remain visible but only the summary will be included in the
active context for Goose. This is useful for long conversations that are approaching active context for Goose. This is useful for long conversations that are approaching
the context limit. the context limit.
</p> </p>
</div> </div>
</Modal>
)} <DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="button" onClick={handleSummarize}>
Summarize
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
}; };

View File

@@ -1,6 +1,14 @@
import React, { useRef, useEffect } from 'react'; import { useRef, useEffect } from 'react';
import { Card } from '../ui/card';
import { Geese } from '../icons/Geese'; import { Geese } from '../icons/Geese';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
interface SessionSummaryModalProps { interface SessionSummaryModalProps {
isOpen: boolean; isOpen: boolean;
@@ -9,40 +17,6 @@ interface SessionSummaryModalProps {
summaryContent: string; summaryContent: string;
} }
// This is a specialized version of BaseModal that's wider just for the SessionSummaryModal
function WiderBaseModal({
isOpen,
title,
children,
actions,
}: {
isOpen: boolean;
title: string;
children: React.ReactNode;
actions: React.ReactNode; // Buttons for actions
}) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-[9999] flex items-center justify-center overflow-y-auto">
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[640px] max-h-[85vh] bg-white dark:bg-gray-800 rounded-xl shadow-xl overflow-hidden p-[16px] pt-[24px] pb-0 flex flex-col">
<div className="px-4 pb-0 space-y-8 flex-grow overflow-hidden">
{/* Header */}
<div className="flex">
<h2 className="text-2xl font-regular dark:text-white text-gray-900">{title}</h2>
</div>
{/* Content - Make it scrollable */}
{children && <div className="px-2 overflow-y-auto max-h-[60vh]">{children}</div>}
{/* Actions */}
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">{actions}</div>
</div>
</Card>
</div>
);
}
export function SessionSummaryModal({ export function SessionSummaryModal({
isOpen, isOpen,
onClose, onClose,
@@ -65,29 +39,27 @@ export function SessionSummaryModal({
onSave(currentText); onSave(currentText);
}; };
// Header Component - Icon, Title, and Description return (
const Header = () => ( <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<div className="flex flex-col items-center text-center mb-6"> <DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
{/* Icon */} <DialogHeader>
<DialogTitle className="flex flex-col items-center text-center">
<div className="mb-4"> <div className="mb-4">
<Geese width="48" height="50" /> <Geese width="48" height="50" />
</div> </div>
Session Summary
</DialogTitle>
<DialogDescription className="text-center max-w-md">
This summary was created to manage your context limit. Review and edit to keep your
session running smoothly with the information that matters most.
</DialogDescription>
</DialogHeader>
{/* Title */} <div className="py-4">
<h2 className="text-xl font-medium text-gray-900 dark:text-white mb-2">Session Summary</h2> <div className="w-full">
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-3">
{/* Description */} Summarization
<p className="text-sm text-gray-600 dark:text-gray-400 mb-0 max-w-md"> </h3>
This summary was created to manage your context limit. Review and edit to keep your session
running smoothly with the information that matters most.
</p>
</div>
);
// Uncontrolled Summary Content Component
const SummaryContent = () => (
<div className="w-full mb-6">
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-3">Summarization</h3>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
@@ -101,32 +73,15 @@ export function SessionSummaryModal({
}} }}
/> />
</div> </div>
); </div>
// Footer Buttons <DialogFooter className="pt-2">
const modalActions = ( <Button variant="outline" onClick={onClose}>
<div>
<button
onClick={handleSave}
className="w-full h-[60px] text-gray-900 dark:text-white font-medium text-base hover:bg-gray-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700"
>
Save and Continue
</button>
<button
onClick={onClose}
className="w-full h-[60px] text-gray-500 dark:text-gray-400 font-medium text-base hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700"
>
Cancel Cancel
</button> </Button>
</div> <Button onClick={handleSave}>Save and Continue</Button>
); </DialogFooter>
</DialogContent>
return ( </Dialog>
<WiderBaseModal isOpen={isOpen} title="" actions={modalActions}>
<div className="flex flex-col w-full">
<Header />
<SummaryContent />
</div>
</WiderBaseModal>
); );
} }

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef, KeyboardEvent } from 'react';
import { Search as SearchIcon } from 'lucide-react'; import { Search as SearchIcon } from 'lucide-react';
import { ArrowDown, ArrowUp, Close } from '../icons'; import { ArrowDown, ArrowUp, Close } from '../icons';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { Button } from '../ui/button';
/** /**
* Props for the SearchBar component * Props for the SearchBar component
@@ -142,13 +143,13 @@ export const SearchBar: React.FC<SearchBarProps> = ({
return ( return (
<div <div
className={`sticky top-0 bg-bgAppInverse text-textProminentInverse z-50 ${ className={`sticky top-0 bg-background-inverse text-text-inverse z-50 mb-4 ${
isExiting ? 'search-bar-exit' : 'search-bar-enter' isExiting ? 'search-bar-exit' : 'search-bar-enter'
}`} }`}
> >
<div className="flex w-full max-w-5xl mx-auto"> <div className="flex w-full items-center">
<div className="relative flex flex-1 items-center h-full"> <div className="relative flex flex-1 items-center h-full min-w-0">
<SearchIcon className="h-4 w-4 text-textSubtleInverse absolute left-3" /> <SearchIcon className="h-4 w-4 text-text-inverse/70 absolute left-3" />
<div className="w-full"> <div className="w-full">
<input <input
ref={inputRef} ref={inputRef}
@@ -158,15 +159,15 @@ export const SearchBar: React.FC<SearchBarProps> = ({
onChange={handleSearch} onChange={handleSearch}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Search conversation..." placeholder="Search conversation..."
className="w-full text-sm pl-9 pr-24 py-3 bg-bgAppInverse className="w-full text-sm pl-9 pr-24 py-3 bg-background-inverse text-text-inverse
placeholder:text-textSubtleInverse focus:outline-none placeholder:text-text-inverse/50 focus:outline-none
active:border-borderProminent" active:border-border-strong"
/> />
</div> </div>
<div className="absolute right-3 flex h-full items-center justify-end"> <div className="absolute right-3 flex h-full items-center justify-end">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="w-16 text-right text-sm text-textStandardInverse flex items-center justify-end"> <div className="w-16 text-right text-sm text-text-inverse/80 flex items-center justify-end">
{(() => { {(() => {
return localSearchResults?.count && localSearchResults.count > 0 && searchTerm return localSearchResults?.count && localSearchResults.count > 0 && searchTerm
? `${localSearchResults.currentIndex}/${localSearchResults.count}` ? `${localSearchResults.currentIndex}/${localSearchResults.count}`
@@ -177,47 +178,51 @@ export const SearchBar: React.FC<SearchBarProps> = ({
</div> </div>
</div> </div>
<div className="flex items-center justify-center h-auto px-4 gap-2"> <div className="flex items-center justify-center h-auto px-4 gap-2 flex-shrink-0">
<button <Button
onClick={toggleCaseSensitive} onClick={toggleCaseSensitive}
variant="ghost"
className={`flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 ${ className={`flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 ${
caseSensitive caseSensitive
? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)]' ? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)] text-text-inverse hover:bg-white/25'
: 'text-textSubtleInverse hover:text-textStandardInverse hover:bg-white/5' : 'text-text-inverse/70 hover:text-text-inverse hover:bg-white/10'
}`} }`}
title="Case Sensitive" title="Case Sensitive"
> >
<span className="text-md font-normal">Aa</span> <span className="text-md font-normal">Aa</span>
</button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={(e) => handleNavigate('prev', e)} className="p-1" title="Previous (↑)"> <Button
onClick={(e) => handleNavigate('prev', e)}
variant="ghost"
className="flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
title="Previous (↑)"
>
<ArrowUp <ArrowUp
className={`h-5 w-5 transition-opacity ${ className={`h-5 w-5 transition-opacity ${!hasResults ? 'opacity-30' : ''}`}
!hasResults
? 'opacity-30'
: 'text-textSubtleInverse hover:text-textStandardInverse'
}`}
/> />
</button> </Button>
<button <Button
onClick={(e) => handleNavigate('next', e)} onClick={(e) => handleNavigate('next', e)}
className="p-1" variant="ghost"
className="flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
title="Next (↓ or Enter)" title="Next (↓ or Enter)"
> >
<ArrowDown <ArrowDown
className={`h-5 w-5 transition-opacity ${ className={`h-5 w-5 transition-opacity ${!hasResults ? 'opacity-30' : ''}`}
!hasResults
? 'opacity-30'
: 'text-textSubtleInverse hover:text-textStandardInverse'
}`}
/> />
</button> </Button>
</div> </div>
<button onClick={handleClose} className="p-1" title="Close (Esc)"> <Button
<Close className="h-5 w-5 text-textSubtleInverse hover:text-textStandardInverse" /> onClick={handleClose}
</button> variant="ghost"
className="flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
title="Close (Esc)"
>
<Close className="h-5 w-5" />
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, PropsWithChildren, useCallback } from 'react'; import React, { useState, useEffect, PropsWithChildren, useCallback, useRef } from 'react';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import { SearchHighlighter } from '../../utils/searchHighlighter'; import { SearchHighlighter } from '../../utils/searchHighlighter';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
@@ -189,32 +189,76 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
[internalSearchResults, onNavigate] [internalSearchResults, onNavigate]
); );
const handleFindCommand = useCallback(() => { // Create stable refs for the handlers to avoid memory leaks
const handlersRef = useRef({
handleFindCommand: () => {
if (isSearchVisible && searchInputRef.current) { if (isSearchVisible && searchInputRef.current) {
searchInputRef.current.focus(); searchInputRef.current.focus();
searchInputRef.current.select(); searchInputRef.current.select();
} else { } else {
setIsSearchVisible(true); setIsSearchVisible(true);
} }
}, [isSearchVisible]); },
handleFindNext: () => {
const handleFindNext = useCallback(() => {
if (isSearchVisible) { if (isSearchVisible) {
handleNavigate('next'); handleNavigate('next');
} }
}, [isSearchVisible, handleNavigate]); },
handleFindPrevious: () => {
const handleFindPrevious = useCallback(() => {
if (isSearchVisible) { if (isSearchVisible) {
handleNavigate('prev'); handleNavigate('prev');
} }
}, [isSearchVisible, handleNavigate]); },
handleUseSelectionFind: () => {
const handleUseSelectionFind = useCallback(() => {
const selection = window.getSelection()?.toString().trim(); const selection = window.getSelection()?.toString().trim();
if (selection) { if (selection) {
setInitialSearchTerm(selection); setInitialSearchTerm(selection);
} }
},
});
// Update the refs with current values
useEffect(() => {
handlersRef.current.handleFindCommand = () => {
if (isSearchVisible && searchInputRef.current) {
searchInputRef.current.focus();
searchInputRef.current.select();
} else {
setIsSearchVisible(true);
}
};
handlersRef.current.handleFindNext = () => {
if (isSearchVisible) {
handleNavigate('next');
}
};
handlersRef.current.handleFindPrevious = () => {
if (isSearchVisible) {
handleNavigate('prev');
}
};
handlersRef.current.handleUseSelectionFind = () => {
const selection = window.getSelection()?.toString().trim();
if (selection) {
setInitialSearchTerm(selection);
}
};
});
const handleFindCommand = useCallback(() => {
handlersRef.current.handleFindCommand();
}, []);
const handleFindNext = useCallback(() => {
handlersRef.current.handleFindNext();
}, []);
const handleFindPrevious = useCallback(() => {
handlersRef.current.handleFindPrevious();
}, []);
const handleUseSelectionFind = useCallback(() => {
handlersRef.current.handleUseSelectionFind();
}, []); }, []);
/** /**
@@ -308,7 +352,8 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
window.electron.off('find-previous', handleFindPrevious); window.electron.off('find-previous', handleFindPrevious);
window.electron.off('use-selection-find', handleUseSelectionFind); window.electron.off('use-selection-find', handleUseSelectionFind);
}; };
}, [handleFindCommand, handleFindNext, handleFindPrevious, handleUseSelectionFind]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array - handlers are stable due to useCallback and useRef
return ( return (
<div <div

View File

@@ -0,0 +1,116 @@
import { View, ViewOptions } from '../../App';
import ExtensionsSection from '../settings/extensions/ExtensionsSection';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Button } from '../ui/button';
import { Plus } from 'lucide-react';
import { GPSIcon } from '../ui/icons';
import { useState } from 'react';
import ExtensionModal from '../settings/extensions/modal/ExtensionModal';
import {
getDefaultFormData,
ExtensionFormData,
createExtensionConfig,
} from '../settings/extensions/utils';
import { activateExtension } from '../settings/extensions/index';
import { useConfig } from '../ConfigContext';
export type ExtensionsViewOptions = {
deepLinkConfig?: ExtensionConfig;
showEnvVars?: boolean;
};
export default function ExtensionsView({
viewOptions,
}: {
onClose: () => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
viewOptions: ExtensionsViewOptions;
}) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const { addExtension } = useConfig();
const handleModalClose = () => {
setIsAddModalOpen(false);
};
const handleAddExtension = async (formData: ExtensionFormData) => {
// Close the modal immediately
handleModalClose();
const extensionConfig = createExtensionConfig(formData);
try {
await activateExtension({ addToConfig: addExtension, extensionConfig: extensionConfig });
// Trigger a refresh of the extensions list
setRefreshKey((prevKey) => prevKey + 1);
} catch (error) {
console.error('Failed to activate extension:', error);
// Even if activation fails, we don't reopen the modal
}
};
return (
<MainPanelLayout>
<div className="flex flex-col min-w-0 flex-1 overflow-y-auto relative">
<div className="bg-background-default px-8 pb-4 pt-16">
<div className="flex flex-col page-transition">
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Extensions</h1>
</div>
<p className="text-sm text-text-muted mb-6">
These extensions use the Model Context Protocol (MCP). They can expand Goose's
capabilities using three main components: Prompts, Resources, and Tools.
</p>
{/* Action Buttons */}
<div className="flex gap-4 mb-8">
<Button
className="flex items-center gap-2 justify-center"
variant="default"
onClick={() => setIsAddModalOpen(true)}
>
<Plus className="h-4 w-4" />
Add custom extension
</Button>
<Button
className="flex items-center gap-2 justify-center"
variant="secondary"
onClick={() =>
window.open('https://block.github.io/goose/v1/extensions/', '_blank')
}
>
<GPSIcon size={12} />
Browse extensions
</Button>
</div>
</div>
</div>
<div className="px-8 pb-16">
<ExtensionsSection
key={refreshKey}
deepLinkConfig={viewOptions.deepLinkConfig}
showEnvVars={viewOptions.showEnvVars}
hideButtons={true}
/>
</div>
{/* Bottom padding space - same as in hub.tsx */}
<div className="block h-8" />
</div>
{/* Modal for adding a new extension */}
{isAddModalOpen && (
<ExtensionModal
title="Add custom extension"
initialData={getDefaultFormData()}
onClose={handleModalClose}
onSubmit={handleAddExtension}
submitLabel="Add Extension"
modalType={'add'}
/>
)}
</MainPanelLayout>
);
}

View File

@@ -0,0 +1,118 @@
/**
* Hub Component
*
* The Hub is the main landing page and entry point for the Goose Desktop application.
* It serves as the welcome screen where users can start new conversations.
*
* Key Responsibilities:
* - Displays SessionInsights to show session statistics and recent chats
* - Provides a ChatInput for users to start new conversations
* - Creates a new chat session with the submitted message and navigates to Pair
* - Ensures each submission from Hub always starts a fresh conversation
*
* Navigation Flow:
* Hub (input submission) → Pair (new conversation with the submitted message)
*
* Unlike the previous implementation that used BaseChat, the Hub now uses only
* ChatInput directly, which allows for clean separation between the landing page
* and active conversation states. This ensures that every message submitted from
* the Hub creates a brand new chat session in the Pair view.
*/
import { useState } from 'react';
import FlappyGoose from './FlappyGoose';
import { type View, ViewOptions } from '../App';
import { Message } from '../types/message';
import { SessionInsights } from './sessions/SessionsInsights';
import ChatInput from './ChatInput';
import { generateSessionId } from '../sessions';
import { ChatContextManagerProvider } from './context_management/ChatContextManager';
import { Recipe } from '../recipe';
import 'react-toastify/dist/ReactToastify.css';
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
}
export default function Hub({
chat: _chat,
setChat: _setChat,
setPairChat,
setView,
setIsGoosehintsModalOpen,
}: {
readyForAutoUserPrompt: boolean;
chat: ChatType;
setChat: (chat: ChatType) => void;
setPairChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
const [showGame, setShowGame] = useState(false);
// Handle chat input submission - create new chat and navigate to pair
const handleSubmit = (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const combinedTextFromInput = customEvent.detail?.value || '';
if (combinedTextFromInput.trim()) {
// Always create a completely new chat session with a unique ID for the PAIR
const newChatId = generateSessionId();
const newPairChat = {
id: newChatId, // This generates a unique ID each time
title: 'New Chat',
messages: [], // Always start with empty messages
messageHistoryIndex: 0,
recipeConfig: null, // Clear recipe for new chats from Hub
};
// Update the PAIR chat state immediately to prevent flashing
setPairChat(newPairChat);
// Navigate to pair page with the message to be submitted immediately
// No delay needed since we're updating state synchronously
setView('pair', {
disableAnimation: true,
initialMessage: combinedTextFromInput,
});
}
// Prevent default form submission
e.preventDefault();
};
return (
<ChatContextManagerProvider>
<div className="flex flex-col h-full bg-background-muted">
<div className="flex-1 flex flex-col mb-0.5">
<SessionInsights />
</div>
<ChatInput
handleSubmit={handleSubmit}
isLoading={false}
onStop={() => {}}
commandHistory={[]}
initialValue=""
setView={setView}
numTokens={0}
inputTokens={0}
outputTokens={0}
droppedFiles={[]}
onFilesProcessed={() => {}}
messages={[]}
setMessages={() => {}}
disableAnimation={false}
sessionCosts={undefined}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
{showGame && <FlappyGoose onClose={() => setShowGame(false)} />}
</div>
</ChatContextManagerProvider>
);
}

View File

@@ -0,0 +1,64 @@
export default function CoinIcon({ className = '', size = 16 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
{/* Main coin circle */}
<circle
cx="10"
cy="10"
r="9"
fill="currentColor"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Inner circle for depth */}
<circle
cx="10"
cy="10"
r="6.5"
fill="none"
stroke="currentColor"
strokeWidth="0.8"
opacity="0.4"
/>
{/* Scalloped edge decoration - 8 evenly spaced notches */}
<circle cx="10" cy="1.5" r="1" fill="currentColor" opacity="0.3" />
<circle cx="15.7" cy="4.3" r="1" fill="currentColor" opacity="0.3" />
<circle cx="18.5" cy="10" r="1" fill="currentColor" opacity="0.3" />
<circle cx="15.7" cy="15.7" r="1" fill="currentColor" opacity="0.3" />
<circle cx="10" cy="18.5" r="1" fill="currentColor" opacity="0.3" />
<circle cx="4.3" cy="15.7" r="1" fill="currentColor" opacity="0.3" />
<circle cx="1.5" cy="10" r="1" fill="currentColor" opacity="0.3" />
<circle cx="4.3" cy="4.3" r="1" fill="currentColor" opacity="0.3" />
{/* Dollar sign */}
<g fill="currentColor" stroke="none">
{/* Vertical line */}
<rect x="9.3" y="5" width="1.4" height="10" />
{/* Top S curve */}
<path
d="M7 7.5 Q7 6.5 8 6.5 L12 6.5 Q13 6.5 13 7.5 Q13 8.5 12 8.5 L8.5 8.5"
strokeWidth="1.2"
stroke="currentColor"
fill="none"
/>
{/* Bottom S curve */}
<path
d="M13 12.5 Q13 13.5 12 13.5 L8 13.5 Q7 13.5 7 12.5 Q7 11.5 8 11.5 L11.5 11.5"
strokeWidth="1.2"
stroke="currentColor"
fill="none"
/>
</g>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
export default function Discord({ className = '' }) {
return (
<svg
width="1.5rem"
height="1.5rem"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 512"
aria-hidden="true"
className={className}
>
<path
d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z"
fill="currentColor"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
export default function LinkedIn({ className = '' }) {
return (
<svg
width="1.5rem"
height="1.5rem"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512"
aria-hidden="true"
className={className}
>
<path
d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z"
fill="currentColor"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
export default function Youtube({ className = '' }) {
return (
<svg
width="1.5rem"
height="1.5rem"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 576 512"
aria-hidden="true"
className={className}
>
<path
d="M549.7 124.1c-6.3-23.7-24.8-42.3-48.3-48.6C458.8 64 288 64 288 64S117.2 64 74.6 75.5c-23.5 6.3-42 24.9-48.3 48.6-11.4 42.9-11.4 132.3-11.4 132.3s0 89.4 11.4 132.3c6.3 23.7 24.8 41.5 48.3 47.8C117.2 448 288 448 288 448s170.8 0 213.4-11.5c23.5-6.3 42-24.2 48.3-47.8 11.4-42.9 11.4-132.3 11.4-132.3s0-89.4-11.4-132.3zm-317.5 213.5V175.2l142.7 81.2-142.7 81.2z"
fill="currentColor"
/>
</svg>
);
}

View File

@@ -8,10 +8,13 @@ import ChevronDown from './ChevronDown';
import ChevronUp from './ChevronUp'; import ChevronUp from './ChevronUp';
import { ChevronRight } from './ChevronRight'; import { ChevronRight } from './ChevronRight';
import Close from './Close'; import Close from './Close';
import CoinIcon from './CoinIcon';
import Copy from './Copy'; import Copy from './Copy';
import Discord from './Discord';
import Document from './Document'; import Document from './Document';
import Edit from './Edit'; import Edit from './Edit';
import Idea from './Idea'; import Idea from './Idea';
import LinkedIn from './LinkedIn';
import More from './More'; import More from './More';
import Refresh from './Refresh'; import Refresh from './Refresh';
import SensitiveHidden from './SensitiveHidden'; import SensitiveHidden from './SensitiveHidden';
@@ -20,6 +23,7 @@ import Send from './Send';
import Settings from './Settings'; import Settings from './Settings';
import Time from './Time'; import Time from './Time';
import { Gear } from './Gear'; import { Gear } from './Gear';
import Youtube from './Youtube';
import { Microphone } from './Microphone'; import { Microphone } from './Microphone';
export { export {
@@ -33,12 +37,15 @@ export {
ChevronRight, ChevronRight,
ChevronUp, ChevronUp,
Close, Close,
CoinIcon,
Copy, Copy,
Discord,
Document, Document,
Edit, Edit,
Idea, Idea,
Gear, Gear,
Microphone, Microphone,
LinkedIn,
More, More,
Refresh, Refresh,
SensitiveHidden, SensitiveHidden,
@@ -46,4 +53,5 @@ export {
Send, Send,
Settings, Settings,
Time, Time,
Youtube,
}; };

View File

@@ -1,480 +0,0 @@
import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '../ui/popover';
import React, { useEffect, useState } from 'react';
import { ChatSmart, Idea, Refresh, Time, Send, Settings } from '../icons';
import { FolderOpen, Moon, Sliders, Sun, Save, FileText } from 'lucide-react';
import { useConfig } from '../ConfigContext';
import { ViewOptions, View } from '../../App';
import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
import { Recipe } from '../../recipe';
// RecipeConfig is used for window creation and should match Recipe interface
type RecipeConfig = Recipe;
interface MenuButtonProps {
onClick: () => void;
children: React.ReactNode;
subtitle?: string;
className?: string;
danger?: boolean;
icon?: React.ReactNode;
testId?: string;
}
const MenuButton: React.FC<MenuButtonProps> = ({
onClick,
children,
subtitle,
className = '',
danger = false,
icon,
testId = '',
}) => (
<button
onClick={onClick}
data-testid={testId}
className={`w-full text-left px-4 py-3 min-h-[64px] text-sm hover:bg-bgSubtle transition-[background] border-b border-borderSubtle ${
danger ? 'text-red-400' : ''
} ${className}`}
>
<div className="flex justify-between items-center">
<div className="flex flex-col">
<span>{children}</span>
{subtitle && (
<span className="text-xs font-regular text-textSubtle mt-0.5">{subtitle}</span>
)}
</div>
{icon && <div className="ml-2">{icon}</div>}
</div>
</button>
);
interface ThemeSelectProps {
themeMode: 'light' | 'dark' | 'system';
onThemeChange: (theme: 'light' | 'dark' | 'system') => void;
}
const ThemeSelect: React.FC<ThemeSelectProps> = ({ themeMode, onThemeChange }) => {
return (
<div className="px-4 py-3 border-b border-borderSubtle">
<div className="text-sm mb-2">Theme</div>
<div className="grid grid-cols-3 gap-2">
<button
data-testid="light-mode-button"
onClick={() => onThemeChange('light')}
className={`flex items-center justify-center gap-2 p-2 rounded-md border transition-colors ${
themeMode === 'light'
? 'border-borderStandard'
: 'border-borderSubtle hover:border-borderStandard text-textSubtle hover:text-textStandard'
}`}
>
<Sun className="h-4 w-4" />
<span className="text-xs">Light</span>
</button>
<button
data-testid="dark-mode-button"
onClick={() => onThemeChange('dark')}
className={`flex items-center justify-center gap-2 p-2 rounded-md border transition-colors ${
themeMode === 'dark'
? 'border-borderStandard'
: 'border-borderSubtle hover:border-borderStandard text-textSubtle hover:text-textStandard'
}`}
>
<Moon className="h-4 w-4" />
<span className="text-xs">Dark</span>
</button>
<button
data-testid="system-mode-button"
onClick={() => onThemeChange('system')}
className={`flex items-center justify-center gap-2 p-2 rounded-md border transition-colors ${
themeMode === 'system'
? 'border-borderStandard'
: 'border-borderSubtle hover:border-borderStandard text-textSubtle hover:text-textStandard'
}`}
>
<Sliders className="h-4 w-4" />
<span className="text-xs">System</span>
</button>
</div>
</div>
);
};
export default function MoreMenu({
setView,
setIsGoosehintsModalOpen,
}: {
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [saveRecipeName, setSaveRecipeName] = useState('');
const [saveGlobal, setSaveGlobal] = useState(true);
const [saving, setSaving] = useState(false);
const { remove } = useConfig();
const [themeMode, setThemeMode] = useState<'light' | 'dark' | 'system'>(() => {
const savedUseSystemTheme = localStorage.getItem('use_system_theme') === 'true';
if (savedUseSystemTheme) {
return 'system';
}
const savedTheme = localStorage.getItem('theme');
return savedTheme === 'dark' ? 'dark' : 'light';
});
const [isDarkMode, setDarkMode] = useState(() => {
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (themeMode === 'system') {
return systemPrefersDark;
}
return themeMode === 'dark';
});
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleThemeChange = (e: { matches: boolean }) => {
if (themeMode === 'system') {
setDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handleThemeChange);
if (themeMode === 'system') {
setDarkMode(mediaQuery.matches);
localStorage.setItem('use_system_theme', 'true');
} else {
setDarkMode(themeMode === 'dark');
localStorage.setItem('use_system_theme', 'false');
localStorage.setItem('theme', themeMode);
}
return () => mediaQuery.removeEventListener('change', handleThemeChange);
}, [themeMode]);
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
} else {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
}
}, [isDarkMode]);
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
setThemeMode(newTheme);
};
const handleSaveRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
// Get the current recipe config from the window with proper validation
const currentRecipeConfig = window.appConfig.get('recipeConfig');
if (!currentRecipeConfig || typeof currentRecipeConfig !== 'object') {
throw new Error('No recipe configuration found');
}
// Validate that it has the required Recipe properties
const recipe = currentRecipeConfig as Recipe;
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
// Save the recipe
const filePath = await saveRecipe(recipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
// Show success message (you might want to use a toast notification instead)
console.log(`Recipe saved to: ${filePath}`);
// Reset dialog state
setShowSaveDialog(false);
setSaveRecipeName('');
setOpen(false);
// Optional: Show a success notification
window.electron.showNotification({
title: 'Recipe Saved',
body: `Recipe "${saveRecipeName}" has been saved successfully.`,
});
} catch (error) {
console.error('Failed to save recipe:', error);
// Show error notification
window.electron.showNotification({
title: 'Save Failed',
body: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
} finally {
setSaving(false);
}
};
const handleSaveRecipeClick = () => {
const currentRecipeConfig = window.appConfig.get('recipeConfig');
if (currentRecipeConfig && typeof currentRecipeConfig === 'object') {
const recipe = currentRecipeConfig as Recipe;
// Generate a suggested name from the recipe title
const suggestedName = generateRecipeFilename(recipe);
setSaveRecipeName(suggestedName);
setShowSaveDialog(true);
setOpen(false);
}
};
const recipeConfig = window.appConfig.get('recipeConfig');
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
data-testid="more-options-button"
className={`z-[100] w-7 h-7 p-1 rounded-full border border-borderSubtle transition-colors cursor-pointer no-drag hover:text-textStandard hover:border-borderStandard ${open ? 'text-textStandard' : 'text-textSubtle'}`}
role="button"
>
<Settings />
</button>
</PopoverTrigger>
<PopoverPortal>
<>
<div
className={`z-[150] fixed inset-0 bg-black transition-all animate-in duration-500 fade-in-0 opacity-50`}
/>
<PopoverContent
className="z-[200] w-[375px] overflow-hidden rounded-lg bg-bgApp border border-borderSubtle text-textStandard !zoom-in-100 !slide-in-from-right-4 !slide-in-from-top-0"
align="end"
sideOffset={5}
>
<div className="flex flex-col rounded-md">
<MenuButton
onClick={() => {
setOpen(false);
window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined
);
}}
subtitle="Start a new session in the current directory"
icon={<ChatSmart className="w-4 h-4" />}
>
New session
<span className="text-textSubtle ml-1">N</span>
</MenuButton>
<MenuButton
onClick={() => {
setOpen(false);
window.electron.directoryChooser();
}}
subtitle="Start a new session in a different directory"
icon={<FolderOpen className="w-4 h-4" />}
>
Open directory
<span className="text-textSubtle ml-1">O</span>
</MenuButton>
<MenuButton
onClick={() => setView('sessions')}
subtitle="View and share previous sessions"
icon={<Time className="w-4 h-4" />}
>
Session history
</MenuButton>
<MenuButton
onClick={() => setView('schedules')}
subtitle="Manage scheduled runs"
icon={<Time className="w-4 h-4" />}
>
Scheduler
</MenuButton>
<MenuButton
onClick={() => setIsGoosehintsModalOpen(true)}
subtitle="Customize instructions"
icon={<Idea className="w-4 h-4" />}
>
Configure .goosehints
</MenuButton>
{recipeConfig ? (
<>
<MenuButton
onClick={() => {
setOpen(false);
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
recipeConfig as RecipeConfig, // recipe config
'recipeEditor' // view type
);
}}
subtitle="View the recipe you're using"
icon={<Send className="w-4 h-4" />}
>
View recipe
</MenuButton>
<MenuButton
onClick={handleSaveRecipeClick}
subtitle="Save this recipe for reuse"
icon={<Save className="w-4 h-4" />}
>
Save recipe
</MenuButton>
</>
) : (
<MenuButton
onClick={() => {
setOpen(false);
// Signal to ChatView that we want to make an agent from the current chat
window.electron.logInfo('Make recipe button clicked');
window.dispatchEvent(new CustomEvent('make-agent-from-chat'));
}}
subtitle="Make a custom agent recipe you can share or reuse with a link"
icon={<Send className="w-4 h-4" />}
>
Make recipe from this session
</MenuButton>
)}
<MenuButton
onClick={() => {
setOpen(false);
setView('recipes');
}}
subtitle="Browse your saved recipes"
icon={<FileText className="w-4 h-4" />}
>
Recipe Library
</MenuButton>
<MenuButton
onClick={() => {
setOpen(false);
setView('settings');
}}
subtitle="View all settings and options"
icon={<Sliders className="w-4 h-4 rotate-90" />}
testId="advanced-settings-button"
>
Advanced settings
<span className="text-textSubtle ml-1">,</span>
</MenuButton>
<ThemeSelect themeMode={themeMode} onThemeChange={handleThemeChange} />
<MenuButton
data-testid="reset-provider-button"
onClick={async () => {
await remove('GOOSE_PROVIDER', false);
await remove('GOOSE_MODEL', false);
setOpen(false);
setView('welcome');
}}
danger
subtitle="Clear selected model and restart (alpha)"
icon={<Refresh className="w-4 h-4 text-textStandard" />}
className="border-b-0"
>
Reset provider and model
</MenuButton>
</div>
</PopoverContent>
</>
</PopoverPortal>
{/* Save Recipe Dialog */}
{showSaveDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-textStandard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)}
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder="Enter recipe name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-textStandard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => {
setShowSaveDialog(false);
setSaveRecipeName('');
}}
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
className="px-4 py-2 bg-borderProminent text-white rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Recipe'}
</button>
</div>
</div>
</div>
)}
</Popover>
);
}

View File

@@ -1,75 +0,0 @@
import { useState } from 'react';
import MoreMenu from './MoreMenu';
import type { View, ViewOptions } from '../../App';
import { Document } from '../icons';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
export default function MoreMenuLayout({
hasMessages,
showMenu = true,
setView,
setIsGoosehintsModalOpen,
}: {
hasMessages?: boolean;
showMenu?: boolean;
setView?: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
}) {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
// Assume macOS if not explicitly set
const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin';
return (
<div
className="relative flex items-center h-14 border-b border-borderSubtle w-full"
style={{ WebkitAppRegion: 'drag' }}
>
{showMenu && (
<div
className={`flex items-center justify-between w-full h-full ${safeIsMacOS ? 'pl-[86px]' : 'pl-[8px]'} pr-4`}
>
<TooltipProvider>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<button
className="z-[100] no-drag hover:cursor-pointer border border-borderSubtle hover:border-borderStandard rounded-lg p-2 pr-3 text-textSubtle hover:text-textStandard text-sm flex items-center transition-colors [&>svg]:size-4 "
onClick={async () => {
if (hasMessages) {
window.electron.directoryChooser();
} else {
window.electron.directoryChooser(true);
}
}}
style={{ minWidth: 0 }}
>
<Document className="mr-1" />
<span
className="flex-grow block text-ellipsis overflow-hidden"
style={{
direction: 'rtl',
textAlign: 'left',
unicodeBidi: 'plaintext',
minWidth: 0,
maxWidth: '100%',
}}
>
{String(window.appConfig.get('GOOSE_WORKING_DIR'))}
</span>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-96 overflow-auto scrollbar-thin" side="top">
{window.appConfig.get('GOOSE_WORKING_DIR') as string}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<MoreMenu
setView={setView || (() => {})}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen || (() => {})}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,217 @@
/**
* Pair Component
*
* The Pair component represents the active conversation mode in the Goose Desktop application.
* This is where users engage in ongoing conversations with the AI assistant after transitioning
* from the Hub's initial welcome screen.
*
* Key Responsibilities:
* - Manages active chat sessions with full message history
* - Handles transitions from Hub with initial input processing
* - Provides the main conversational interface for extended interactions
* - Enables local storage persistence for conversation continuity
* - Supports all advanced chat features like file attachments, tool usage, etc.
*
* Navigation Flow:
* Hub (initial message) → Pair (active conversation) → Hub (new session)
*
* The Pair component is essentially a specialized wrapper around BaseChat that:
* - Processes initial input from the Hub transition
* - Enables conversation persistence
* - Provides the full-featured chat experience
*
* Unlike Hub, Pair assumes an active conversation state and focuses on
* maintaining conversation flow rather than onboarding new users.
*/
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { type View, ViewOptions } from '../App';
import { Message } from '../types/message';
import BaseChat from './BaseChat';
import ParameterInputModal from './ParameterInputModal';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useIsMobile } from '../hooks/use-mobile';
import { useSidebar } from './ui/sidebar';
import { Recipe } from '../recipe';
import 'react-toastify/dist/ReactToastify.css';
import { cn } from '../utils';
export interface ChatType {
id: string;
title: string;
messageHistoryIndex: number;
messages: Message[];
recipeConfig?: Recipe | null; // Add recipe configuration to chat state
}
export default function Pair({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
const location = useLocation();
const isMobile = useIsMobile();
const { state: sidebarState } = useSidebar();
const [hasProcessedInitialInput, setHasProcessedInitialInput] = useState(false);
const [shouldAutoSubmit, setShouldAutoSubmit] = useState(false);
const [initialMessage, setInitialMessage] = useState<string | null>(null);
const [isTransitioningFromHub, setIsTransitioningFromHub] = useState(false);
// Get recipe configuration and parameter handling
const {
recipeConfig,
initialPrompt: recipeInitialPrompt,
isParameterModalOpen,
setIsParameterModalOpen,
handleParameterSubmit,
} = useRecipeManager(chat.messages, location.state);
// Handle recipe loading from recipes view - reset chat if needed
useEffect(() => {
if (location.state?.resetChat && location.state?.recipeConfig) {
// Reset the chat to start fresh with the recipe
const newChat = {
id: chat.id, // Keep the same ID to maintain the session
title: location.state.recipeConfig.title || 'Recipe Chat',
messages: [], // Clear messages to start fresh
messageHistoryIndex: 0,
recipeConfig: location.state.recipeConfig, // Set the recipe config in chat state
};
setChat(newChat);
// Clear the location state to prevent re-processing
window.history.replaceState({}, '', '/pair');
}
}, [location.state, chat.id, setChat]);
// Handle initial message from hub page
useEffect(() => {
const messageFromHub = location.state?.initialMessage;
// Reset processing state when we have a new message from hub
if (messageFromHub) {
// Set transitioning state to prevent showing popular topics
setIsTransitioningFromHub(true);
// If this is a different message than what we processed before, reset the flag
if (messageFromHub !== initialMessage) {
setHasProcessedInitialInput(false);
}
if (!hasProcessedInitialInput) {
setHasProcessedInitialInput(true);
setInitialMessage(messageFromHub);
setShouldAutoSubmit(true);
// Clear the location state to prevent re-processing
window.history.replaceState({}, '', '/pair');
}
}
}, [location.state, hasProcessedInitialInput, initialMessage, chat]);
// Auto-submit the initial message after it's been set and component is ready
useEffect(() => {
if (shouldAutoSubmit && initialMessage) {
// Wait for the component to be fully rendered
const timer = setTimeout(() => {
// Try to trigger form submission programmatically
const textarea = document.querySelector(
'textarea[data-testid="chat-input"]'
) as HTMLTextAreaElement;
const form = textarea?.closest('form');
if (textarea && form) {
// Set the textarea value
textarea.value = initialMessage;
// eslint-disable-next-line no-undef
textarea.dispatchEvent(new Event('input', { bubbles: true }));
// Focus the textarea
textarea.focus();
// Simulate Enter key press to trigger submission
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
});
textarea.dispatchEvent(enterEvent);
setShouldAutoSubmit(false);
}
}, 500); // Give more time for the component to fully mount
// eslint-disable-next-line no-undef
return () => clearTimeout(timer);
}
// Return undefined when condition is not met
return undefined;
}, [shouldAutoSubmit, initialMessage]);
// Custom message submit handler
const handleMessageSubmit = (message: string) => {
// This is called after a message is submitted
setShouldAutoSubmit(false);
setIsTransitioningFromHub(false); // Clear transitioning state once message is submitted
console.log('Message submitted:', message);
};
// Custom message stream finish handler to handle recipe auto-execution
const handleMessageStreamFinish = () => {
// This will be called with the proper append function from BaseChat
// For now, we'll handle auto-execution in the BaseChat component
};
// Determine the initial value for the chat input
// Priority: Hub message > Recipe prompt > empty
const initialValue = initialMessage || recipeInitialPrompt || undefined;
// Custom chat input props for Pair-specific behavior
const customChatInputProps = {
// Pass initial message from Hub or recipe prompt
initialValue,
};
// Custom content before messages
const renderBeforeMessages = () => {
return <div>{/* Any Pair-specific content before messages can go here */}</div>;
};
return (
<>
<BaseChat
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
enableLocalStorage={true} // Enable local storage for Pair mode
onMessageSubmit={handleMessageSubmit}
onMessageStreamFinish={handleMessageStreamFinish}
renderBeforeMessages={renderBeforeMessages}
customChatInputProps={customChatInputProps}
contentClassName={cn('pr-1', (isMobile || sidebarState === 'collapsed') && 'pt-11')} // Use dynamic content class with mobile margin and sidebar state
showPopularTopics={!isTransitioningFromHub} // Don't show popular topics while transitioning from Hub
suppressEmptyState={isTransitioningFromHub} // Suppress all empty state content while transitioning from Hub
/>
{/* Recipe Parameter Modal */}
{isParameterModalOpen && recipeConfig?.parameters && (
<ParameterInputModal
parameters={recipeConfig.parameters}
onSubmit={handleParameterSubmit}
onClose={() => setIsParameterModalOpen(false)}
/>
)}
</>
);
}

View File

@@ -14,7 +14,8 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
return ( return (
<div className="parameter-input my-4 p-4 border rounded-lg bg-bgSubtle shadow-sm"> <div className="parameter-input my-4 p-4 border rounded-lg bg-bgSubtle shadow-sm">
<h3 className="text-lg font-bold text-textProminent mb-4"> <h3 className="text-lg font-bold text-textProminent mb-4">
Parameter: <code className="bg-bgApp px-2 py-1 rounded-md">{parameter.key}</code> Parameter:{' '}
<code className="bg-background-default px-2 py-1 rounded-md">{parameter.key}</code>
</h3> </h3>
<div className="mb-4"> <div className="mb-4">
@@ -23,7 +24,7 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
type="text" type="text"
value={description || ''} value={description || ''}
onChange={(e) => onChange(key, { description: e.target.value })} onChange={(e) => onChange(key, { description: e.target.value })}
className="w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent" className="w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
placeholder={`E.g., "Enter the name for the new component"`} placeholder={`E.g., "Enter the name for the new component"`}
/> />
<p className="text-sm text-textSubtle mt-1">This is the message the end-user will see.</p> <p className="text-sm text-textSubtle mt-1">This is the message the end-user will see.</p>
@@ -34,7 +35,7 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
<div> <div>
<label className="block text-md text-textStandard mb-2 font-semibold">Requirement</label> <label className="block text-md text-textStandard mb-2 font-semibold">Requirement</label>
<select <select
className="w-full p-3 border rounded-lg bg-bgApp text-textStandard" className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
value={requirement} value={requirement}
onChange={(e) => onChange={(e) =>
onChange(key, { requirement: e.target.value as Parameter['requirement'] }) onChange(key, { requirement: e.target.value as Parameter['requirement'] })
@@ -55,7 +56,7 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
type="text" type="text"
value={defaultValue} value={defaultValue}
onChange={(e) => onChange(key, { default: e.target.value })} onChange={(e) => onChange(key, { default: e.target.value })}
className="w-full p-3 border rounded-lg bg-bgApp text-textStandard" className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
placeholder="Enter default value" placeholder="Enter default value"
/> />
</div> </div>

View File

@@ -0,0 +1,146 @@
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;

View File

@@ -0,0 +1,151 @@
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;

View File

@@ -0,0 +1,47 @@
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;

View File

@@ -0,0 +1,498 @@
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;

View File

@@ -0,0 +1,33 @@
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;

View File

@@ -0,0 +1,214 @@
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;

View File

@@ -0,0 +1,181 @@
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;

View File

@@ -96,12 +96,12 @@ const daysOfWeekOptions: { value: string; label: string }[] = [
{ value: '0', label: 'Sun' }, { value: '0', label: 'Sun' },
]; ];
const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'; const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1';
const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1'; const cronPreviewTextColor = 'text-xs text-text-subtle mt-1';
const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1'; const cronPreviewSpecialNoteColor = 'text-xs text-text-warning mt-1';
const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300'; const checkboxLabelClassName = 'flex items-center text-sm text-text-default';
const checkboxInputClassName = const checkboxInputClassName =
'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2'; 'h-4 w-4 text-accent-default border-border-subtle rounded focus:ring-accent-default mr-2';
type SourceType = 'file' | 'deeplink'; type SourceType = 'file' | 'deeplink';
type ExecutionMode = 'background' | 'foreground'; type ExecutionMode = 'background' | 'foreground';
@@ -543,15 +543,13 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden"> <Card className="w-full max-w-md bg-background-default shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
<div className="px-8 pt-8 pb-4 flex-shrink-0 text-center"> <div className="px-8 pt-8 pb-4 flex-shrink-0 text-center">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<img src={ClockIcon} alt="Clock" className="w-11 h-11 mb-2" /> <img src={ClockIcon} alt="Clock" className="w-11 h-11 mb-2" />
<h2 className="text-base font-semibold text-gray-900 dark:text-white"> <h2 className="text-base font-semibold text-text-prominent">Create New Schedule</h2>
Create New Schedule <p className="text-base text-text-subtle mt-2 max-w-sm">
</h2>
<p className="text-base text-gray-500 dark:text-gray-400 mt-2 max-w-sm">
Create a new schedule using the settings below to do things like automatically run Create a new schedule using the settings below to do things like automatically run
tasks or create files tasks or create files
</p> </p>
@@ -564,12 +562,12 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
className="px-8 py-4 space-y-4 flex-grow overflow-y-auto" className="px-8 py-4 space-y-4 flex-grow overflow-y-auto"
> >
{apiErrorExternally && ( {apiErrorExternally && (
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50"> <p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
{apiErrorExternally} {apiErrorExternally}
</p> </p>
)} )}
{internalValidationError && ( {internalValidationError && (
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50"> <p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
{internalValidationError} {internalValidationError}
</p> </p>
)} )}

View File

@@ -348,8 +348,8 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden"> <Card className="w-full max-w-md bg-background-default shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden">
<div className="px-6 pt-6 pb-4 flex-shrink-0"> <div className="px-6 pt-6 pb-4 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white"> <h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Edit Schedule: {schedule?.id || ''} Edit Schedule: {schedule?.id || ''}
@@ -538,9 +538,9 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
<Button <Button
type="submit" type="submit"
form="edit-schedule-form" form="edit-schedule-form"
variant="default" variant="ghost"
disabled={isLoadingExternally} disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-lg dark:text-white dark:border-gray-600 font-regular" className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
> >
{isLoadingExternally ? 'Updating...' : 'Update Schedule'} {isLoadingExternally ? 'Updating...' : 'Update Schedule'}
</Button> </Button>

View File

@@ -3,7 +3,6 @@ import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import BackButton from '../ui/BackButton'; import BackButton from '../ui/BackButton';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
import { fetchSessionDetails, SessionDetails } from '../../sessions'; import { fetchSessionDetails, SessionDetails } from '../../sessions';
import { import {
getScheduleSessions, getScheduleSessions,
@@ -67,12 +66,10 @@ const ScheduleInfoCard = React.memo<{
}, [scheduleDetails.process_start_time]); }, [scheduleDetails.process_start_time]);
return ( return (
<Card className="p-4 bg-white dark:bg-gray-800 shadow mb-6"> <Card className="p-4 bg-background-card shadow mb-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-col md:flex-row md:items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between">
<h3 className="text-base font-semibold text-gray-900 dark:text-white"> <h3 className="text-base font-semibold text-text-prominent">{scheduleDetails.id}</h3>
{scheduleDetails.id}
</h3>
<div className="mt-2 md:mt-0 flex items-center gap-2"> <div className="mt-2 md:mt-0 flex items-center gap-2">
{scheduleDetails.currently_running && ( {scheduleDetails.currently_running && (
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center"> <div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
@@ -88,20 +85,20 @@ const ScheduleInfoCard = React.memo<{
)} )}
</div> </div>
</div> </div>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-text-default">
<span className="font-semibold">Schedule:</span> {readableCron} <span className="font-semibold">Schedule:</span> {readableCron}
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-text-default">
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron} <span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-text-default">
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source} <span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-text-default">
<span className="font-semibold">Last Run:</span> {formattedLastRun} <span className="font-semibold">Last Run:</span> {formattedLastRun}
</p> </p>
{scheduleDetails.execution_mode && ( {scheduleDetails.execution_mode && (
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-text-default">
<span className="font-semibold">Execution Mode:</span>{' '} <span className="font-semibold">Execution Mode:</span>{' '}
<span <span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
@@ -115,13 +112,13 @@ const ScheduleInfoCard = React.memo<{
</p> </p>
)} )}
{scheduleDetails.currently_running && scheduleDetails.current_session_id && ( {scheduleDetails.currently_running && scheduleDetails.current_session_id && (
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-text-default">
<span className="font-semibold">Current Session:</span>{' '} <span className="font-semibold">Current Session:</span>{' '}
{scheduleDetails.current_session_id} {scheduleDetails.current_session_id}
</p> </p>
)} )}
{scheduleDetails.currently_running && formattedProcessStartTime && ( {scheduleDetails.currently_running && formattedProcessStartTime && (
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-text-default">
<span className="font-semibold">Process Started:</span> {formattedProcessStartTime} <span className="font-semibold">Process Started:</span> {formattedProcessStartTime}
</p> </p>
)} )}
@@ -486,13 +483,10 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
if (!scheduleId) { if (!scheduleId) {
return ( return (
<div className="h-screen w-full flex flex-col items-center justify-center bg-app text-textStandard p-8"> <div className="h-screen w-full flex flex-col items-center justify-center bg-white dark:bg-gray-900 text-text-default p-8">
<MoreMenuLayout showMenu={false} />
<BackButton onClick={onNavigateBack} /> <BackButton onClick={onNavigateBack} />
<h1 className="text-2xl font-medium text-gray-900 dark:text-white mt-4"> <h1 className="text-2xl font-medium text-text-prominent mt-4">Schedule Not Found</h1>
Schedule Not Found <p className="text-text-subtle mt-2">
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
No schedule ID was provided. Please return to the schedules list and select a schedule. No schedule ID was provided. Please return to the schedules list and select a schedule.
</p> </p>
</div> </div>
@@ -500,31 +494,24 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
} }
return ( return (
<div className="h-screen w-full flex flex-col bg-app text-textStandard"> <div className="h-screen w-full flex flex-col bg-background-default text-text-default">
<MoreMenuLayout showMenu={false} /> <div className="px-8 pt-6 pb-4 border-b border-border-subtle flex-shrink-0">
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
<BackButton onClick={onNavigateBack} /> <BackButton onClick={onNavigateBack} />
<h1 className="text-3xl font-medium text-gray-900 dark:text-white mt-1"> <h1 className="text-3xl font-medium text-text-prominent mt-1">Schedule Details</h1>
Schedule Details <p className="text-sm text-text-subtle mt-1">Viewing Schedule ID: {scheduleId}</p>
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Viewing Schedule ID: {scheduleId}
</p>
</div> </div>
<ScrollArea className="flex-grow"> <ScrollArea className="flex-grow">
<div className="p-8 space-y-6"> <div className="p-8 space-y-6">
<section> <section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3"> <h2 className="text-xl font-semibold text-text-prominent mb-3">Schedule Information</h2>
Schedule Information
</h2>
{isLoadingSchedule && ( {isLoadingSchedule && (
<div className="flex items-center text-gray-500 dark:text-gray-400"> <div className="flex items-center text-text-subtle">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule details... <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule details...
</div> </div>
)} )}
{scheduleError && ( {scheduleError && (
<p className="text-red-500 dark:text-red-400 text-sm p-3 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md"> <p className="text-text-error text-sm p-3 bg-background-error border border-border-error rounded-md">
Error: {scheduleError} Error: {scheduleError}
</p> </p>
)} )}
@@ -534,7 +521,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
</section> </section>
<section> <section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">Actions</h2> <h2 className="text-xl font-semibold text-text-prominent mb-3">Actions</h2>
<div className="flex flex-col md:flex-row gap-2"> <div className="flex flex-col md:flex-row gap-2">
<Button <Button
onClick={handleRunNow} onClick={handleRunNow}
@@ -619,19 +606,17 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
</section> </section>
<section> <section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-semibold text-text-prominent mb-4">
Recent Sessions for this Schedule Recent Sessions for this Schedule
</h2> </h2>
{isLoadingSessions && ( {isLoadingSessions && <p className="text-text-subtle">Loading sessions...</p>}
<p className="text-gray-500 dark:text-gray-400">Loading sessions...</p>
)}
{sessionsError && ( {sessionsError && (
<p className="text-red-500 dark:text-red-400 text-sm p-3 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md"> <p className="text-text-error text-sm p-3 bg-background-error border border-border-error rounded-md">
Error: {sessionsError} Error: {sessionsError}
</p> </p>
)} )}
{!isLoadingSessions && !sessionsError && sessions.length === 0 && ( {!isLoadingSessions && !sessionsError && sessions.length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-center py-4"> <p className="text-text-subtle text-center py-4">
No sessions found for this schedule. No sessions found for this schedule.
</p> </p>
)} )}
@@ -641,7 +626,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
{sessions.map((session) => ( {sessions.map((session) => (
<Card <Card
key={session.id} key={session.id}
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200" className="p-4 bg-background-card shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
onClick={() => handleSessionCardClick(session.id)} onClick={() => handleSessionCardClick(session.id)}
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -652,23 +637,23 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
}} }}
> >
<h3 <h3
className="text-sm font-semibold text-gray-900 dark:text-white truncate" className="text-sm font-semibold text-text-prominent truncate"
title={session.name || session.id} title={session.name || session.id}
> >
{session.name || `Session ID: ${session.id}`}{' '} {session.name || `Session ID: ${session.id}`}{' '}
</h3> </h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-text-subtle mt-1">
Created:{' '} Created:{' '}
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'} {session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
</p> </p>
{session.messageCount !== undefined && ( {session.messageCount !== undefined && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-text-subtle mt-1">
Messages: {session.messageCount} Messages: {session.messageCount}
</p> </p>
)} )}
{session.workingDir && ( {session.workingDir && (
<p <p
className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate" className="text-xs text-text-subtle mt-1 truncate"
title={session.workingDir} title={session.workingDir}
> >
Dir: {session.workingDir} Dir: {session.workingDir}
@@ -676,11 +661,11 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
)} )}
{session.accumulatedTotalTokens !== undefined && {session.accumulatedTotalTokens !== undefined &&
session.accumulatedTotalTokens !== null && ( session.accumulatedTotalTokens !== null && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-text-subtle mt-1">
Tokens: {session.accumulatedTotalTokens} Tokens: {session.accumulatedTotalTokens}
</p> </p>
)} )}
<p className="text-xs text-gray-600 dark:text-gray-500 mt-1"> <p className="text-xs text-text-muted mt-1">
ID: <span className="font-mono">{session.id}</span> ID: <span className="font-mono">{session.id}</span>
</p> </p>
</Card> </Card>

View File

@@ -69,8 +69,8 @@ export const ScheduleFromRecipeModal: React.FC<ScheduleFromRecipeModalProps> = (
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-lg z-50 flex flex-col"> <Card className="w-full max-w-md bg-background-default shadow-xl rounded-lg z-50 flex flex-col">
<div className="px-6 pt-6 pb-4"> <div className="px-6 pt-6 pb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white"> <h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Create Schedule from Recipe Create Schedule from Recipe
@@ -123,7 +123,7 @@ export const ScheduleFromRecipeModal: React.FC<ScheduleFromRecipeModalProps> = (
<Button <Button
type="button" type="button"
onClick={handleCreateSchedule} onClick={handleCreateSchedule}
className="flex-1 bg-bgAppInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors" className="flex-1 bg-background-defaultInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
> >
Create Schedule Create Schedule
</Button> </Button>

View File

@@ -10,23 +10,21 @@ import {
inspectRunningJob, inspectRunningJob,
ScheduledJob, ScheduledJob,
} from '../../schedule'; } from '../../schedule';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { TrashIcon } from '../icons/TrashIcon'; import { TrashIcon } from '../icons/TrashIcon';
import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye, MoreHorizontal } from 'lucide-react'; import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye, CircleDotDashed } from 'lucide-react';
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal'; import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
import { EditScheduleModal } from './EditScheduleModal'; import { EditScheduleModal } from './EditScheduleModal';
import ScheduleDetailView from './ScheduleDetailView'; import ScheduleDetailView from './ScheduleDetailView';
import { toastError, toastSuccess } from '../../toasts'; import { toastError, toastSuccess } from '../../toasts';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import cronstrue from 'cronstrue'; import cronstrue from 'cronstrue';
import { formatToLocalDateWithTimezone } from '../../utils/date'; import { formatToLocalDateWithTimezone } from '../../utils/date';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
interface SchedulesViewProps { interface SchedulesViewProps {
onClose: () => void; onClose?: () => void;
} }
// Memoized ScheduleCard component to prevent unnecessary re-renders // Memoized ScheduleCard component to prevent unnecessary re-renders
@@ -75,89 +73,64 @@ const ScheduleCard = React.memo<{
return ( return (
<Card <Card
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200" className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150"
onClick={() => onNavigateToDetail(job.id)} onClick={() => onNavigateToDetail(job.id)}
> >
<div className="flex justify-between items-start"> <div className="flex justify-between items-start gap-4">
<div className="flex-grow mr-2 overflow-hidden"> <div className="min-w-0 flex-1">
<h3 <div className="flex items-center gap-2 mb-1">
className="text-base font-semibold text-gray-900 dark:text-white truncate" <h3 className="text-base truncate max-w-[50vw]" title={job.id}>
title={job.id}
>
{job.id} {job.id}
</h3> </h3>
<p
className="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all"
title={job.source}
>
Source: {job.source}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1" title={readableCron}>
Schedule: {readableCron}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Last Run: {formattedLastRun}
</p>
{job.execution_mode && ( {job.execution_mode && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mode:{' '}
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
job.execution_mode === 'foreground' job.execution_mode === 'foreground'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' ? 'bg-background-accent text-text-on-accent'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' : 'bg-background-medium text-text-default'
}`} }`}
> >
{job.execution_mode === 'foreground' ? '🖥️ Foreground' : '⚡ Background'} {job.execution_mode === 'foreground' ? '🖥️' : '⚡'}
</span> </span>
</p>
)} )}
{job.currently_running && ( {job.currently_running && (
<p className="text-xs text-green-500 dark:text-green-400 mt-1 font-semibold flex items-center"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span> <span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Currently Running Running
</p> </span>
)} )}
{job.paused && ( {job.paused && (
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1 font-semibold flex items-center"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
<Pause className="w-3 h-3 mr-1" /> <Pause className="w-3 h-3 mr-1" />
Paused Paused
</p> </span>
)} )}
</div> </div>
<div className="flex-shrink-0"> <p className="text-text-muted text-sm mb-2 line-clamp-2" title={readableCron}>
<Popover> {readableCron}
<PopoverTrigger asChild> </p>
<Button <div className="flex items-center text-xs text-text-muted">
variant="ghost" <span>Last run: {formattedLastRun}</span>
size="icon" </div>
onClick={(e) => { </div>
e.stopPropagation();
}} <div className="flex items-center gap-2 shrink-0">
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100/50 dark:hover:bg-gray-800/50"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-48 p-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 shadow-lg"
align="end"
>
<div className="space-y-1">
{!job.currently_running && ( {!job.currently_running && (
<> <>
<button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit(job); onEdit(job);
}} }}
disabled={isPausing || isDeleting || isSubmitting} disabled={isPausing || isDeleting || isSubmitting}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed" variant="outline"
size="sm"
className="h-8"
> >
<span>Edit</span> <Edit className="w-4 h-4 mr-1" />
<Edit className="w-4 h-4" /> Edit
</button> </Button>
<button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (job.paused) { if (job.paused) {
@@ -167,54 +140,66 @@ const ScheduleCard = React.memo<{
} }
}} }}
disabled={isPausing || isDeleting} disabled={isPausing || isDeleting}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed" variant="outline"
size="sm"
className="h-8"
> >
<span>{job.paused ? 'Resume schedule' : 'Stop schedule'}</span> {job.paused ? (
{job.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />} <>
</button> <Play className="w-4 h-4 mr-1" />
Resume
</>
) : (
<>
<Pause className="w-4 h-4 mr-1" />
Pause
</>
)}
</Button>
</> </>
)} )}
{job.currently_running && ( {job.currently_running && (
<> <>
<button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onInspect(job.id); onInspect(job.id);
}} }}
disabled={isInspecting || isKilling} disabled={isInspecting || isKilling}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed" variant="outline"
size="sm"
className="h-8"
> >
<span>Inspect</span> <Eye className="w-4 h-4 mr-1" />
<Eye className="w-4 h-4" /> Inspect
</button> </Button>
<button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onKill(job.id); onKill(job.id);
}} }}
disabled={isKilling || isInspecting} disabled={isKilling || isInspecting}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed" variant="outline"
size="sm"
className="h-8"
> >
<span>Kill job</span> <Square className="w-4 h-4 mr-1" />
<Square className="w-4 h-4" /> Kill
</button> </Button>
</> </>
)} )}
<hr className="border-gray-200 dark:border-gray-600 my-1" /> <Button
<button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(job.id); onDelete(job.id);
}} }}
disabled={isPausing || isDeleting || isKilling || isInspecting} disabled={isPausing || isDeleting || isKilling || isInspecting}
className="w-full flex items-center justify-between px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed" variant="ghost"
size="sm"
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
> >
<span>Delete</span>
<TrashIcon className="w-4 h-4" /> <TrashIcon className="w-4 h-4" />
</button> </Button>
</div>
</PopoverContent>
</Popover>
</div> </div>
</div> </div>
</Card> </Card>
@@ -224,7 +209,7 @@ const ScheduleCard = React.memo<{
ScheduleCard.displayName = 'ScheduleCard'; ScheduleCard.displayName = 'ScheduleCard';
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => { const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
const [schedules, setSchedules] = useState<ScheduledJob[]>([]); const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -573,57 +558,64 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
} }
return ( return (
<div className="h-screen w-full flex flex-col bg-app text-textStandard"> <>
<MoreMenuLayout showMenu={false} /> <MainPanelLayout>
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0"> <div className="flex-1 flex flex-col min-h-0">
<BackButton onClick={onClose} /> <div className="bg-background-default px-8 pb-8 pt-16">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mt-2"> <div className="flex flex-col page-transition">
Schedules Management <div className="flex justify-between items-center mb-1">
</h1> <h1 className="text-4xl font-light">Scheduler</h1>
</div> <div className="flex gap-2">
<ScrollArea className="flex-grow">
<div className="p-8">
<div className="flex flex-col md:flex-row gap-2 mb-8">
<Button
onClick={handleOpenCreateModal}
className="w-full md:w-auto flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4"
>
<Plus className="h-4 w-4" /> Create New Schedule
</Button>
<Button <Button
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing || isLoading} disabled={isRefreshing || isLoading}
variant="outline" variant="outline"
className="w-full md:w-auto flex items-center gap-2 justify-center rounded-full [&>svg]:!size-4" size="sm"
className="flex items-center gap-2"
> >
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Refreshing...' : 'Refresh'} {isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button> </Button>
<Button
onClick={handleOpenCreateModal}
size="sm"
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Create Schedule
</Button>
</div>
</div>
<p className="text-sm text-text-muted mb-1">
Create and manage scheduled tasks to run recipes automatically at specified times.
</p>
</div>
</div> </div>
<div className="flex-1 min-h-0 relative px-8">
<ScrollArea className="h-full">
<div className="h-full relative">
{apiError && ( {apiError && (
<p className="text-red-500 dark:text-red-400 text-sm p-4 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md"> <div className="mb-4 p-4 bg-background-error border border-border-error rounded-md">
Error: {apiError} <p className="text-text-error text-sm">Error: {apiError}</p>
</p> </div>
)} )}
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Existing Schedules
</h2>
{isLoading && schedules.length === 0 && ( {isLoading && schedules.length === 0 && (
<p className="text-gray-500 dark:text-gray-400">Loading schedules...</p> <div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-text-default"></div>
</div>
)} )}
{!isLoading && !apiError && schedules.length === 0 && ( {!isLoading && !apiError && schedules.length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-center py-4"> <div className="flex flex-col pt-4 pb-12">
No schedules found. Create one to get started! <CircleDotDashed className="h-5 w-5 text-text-muted mb-3.5" />
</p> <p className="text-base text-text-muted font-light mb-2">No schedules yet</p>
</div>
)} )}
{!isLoading && schedules.length > 0 && ( {!isLoading && schedules.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="space-y-2 pb-8">
{schedules.map((job) => ( {schedules.map((job) => (
<ScheduleCard <ScheduleCard
key={job.id} key={job.id}
@@ -644,9 +636,12 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
))} ))}
</div> </div>
)} )}
</section>
</div> </div>
</ScrollArea> </ScrollArea>
</div>
</div>
</MainPanelLayout>
<CreateScheduleModal <CreateScheduleModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={handleCloseCreateModal} onClose={handleCloseCreateModal}
@@ -662,7 +657,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
isLoadingExternally={isSubmitting} isLoadingExternally={isSubmitting}
apiErrorExternally={submitApiError} apiErrorExternally={submitApiError}
/> />
</div> </>
); );
}; };

View File

@@ -9,15 +9,65 @@ import {
Check, Check,
Target, Target,
LoaderCircle, LoaderCircle,
AlertCircle,
ChevronLeft,
ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
import { type SessionDetails } from '../../sessions'; import { type SessionDetails } from '../../sessions';
import { SessionHeaderCard, SessionMessages } from './SessionViewComponents';
import { formatMessageTimestamp } from '../../utils/timeUtils';
import { createSharedSession } from '../../sharedSessions';
import { Modal, ModalContent } from '../ui/modal';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { ScrollArea } from '../ui/scroll-area';
import { formatMessageTimestamp } from '../../utils/timeUtils';
import { createSharedSession } from '../../sharedSessions';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
import ProgressiveMessageList from '../ProgressiveMessageList';
import { SearchView } from '../conversation/SearchView';
import { ChatContextManagerProvider } from '../context_management/ChatContextManager';
import { Message } from '../../types/message';
// Helper function to determine if a message is a user message (same as useChatEngine)
const isUserMessage = (message: Message): boolean => {
if (message.role === 'assistant') {
return false;
}
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
return false;
}
return true;
};
// Filter messages for display (same logic as useChatEngine)
const filterMessagesForDisplay = (messages: Message[]): Message[] => {
return messages.filter((message) => {
// Only filter out when display is explicitly false
if (message.display === false) return false;
// Keep all assistant messages and user messages that aren't just tool responses
if (message.role === 'assistant') return true;
// For user messages, check if they're only tool responses
if (message.role === 'user') {
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
const hasTextContent = message.content.some((c) => c.type === 'text');
const hasToolConfirmation = message.content.every(
(c) => c.type === 'toolConfirmationRequest'
);
// Keep the message if it has text content or tool confirmation or is not just tool responses
return hasTextContent || !hasOnlyToolResponses || hasToolConfirmation;
}
return true;
});
};
interface SessionHistoryViewProps { interface SessionHistoryViewProps {
session: SessionDetails; session: SessionDetails;
@@ -29,6 +79,94 @@ interface SessionHistoryViewProps {
showActionButtons?: boolean; showActionButtons?: boolean;
} }
// Custom SessionHeader component similar to SessionListView style
const SessionHeader: React.FC<{
onBack: () => void;
children: React.ReactNode;
title: string;
actionButtons?: React.ReactNode;
}> = ({ onBack, children, title, actionButtons }) => {
return (
<div className="flex flex-col pb-8 border-b">
<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>
);
};
// Session messages component that uses the same rendering as BaseChat
const SessionMessages: React.FC<{
messages: Message[];
isLoading: boolean;
error: string | null;
onRetry: () => void;
}> = ({ messages, isLoading, error, onRetry }) => {
// Filter messages for display (same as BaseChat)
const filteredMessages = filterMessagesForDisplay(messages);
return (
<ScrollArea className="h-full w-full">
<div className="pb-24 pt-8">
<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 Session Details</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={onRetry} variant="default">
Try Again
</Button>
</div>
) : filteredMessages?.length > 0 ? (
<ChatContextManagerProvider>
<div className="max-w-4xl mx-auto w-full">
<SearchView>
<ProgressiveMessageList
messages={filteredMessages}
chat={{
id: 'session-preview',
messageHistoryIndex: filteredMessages.length,
}}
toolCallNotifications={new Map()}
append={() => {}} // Read-only for session history
appendMessage={(newMessage) => {
// Read-only - do nothing
console.log('appendMessage called in read-only session history:', newMessage);
}}
isUserMessage={isUserMessage} // Use the same function as BaseChat
batchSize={15} // Same as BaseChat default
batchDelay={30} // Same as BaseChat default
showLoadingThreshold={30} // Same as BaseChat default
/>
</SearchView>
</div>
</ChatContextManagerProvider>
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquareText className="w-12 h-12 mb-4" />
<p className="text-lg mb-2">No messages found</p>
<p className="text-sm">This session doesn't contain any messages</p>
</div>
)}
</div>
</div>
</ScrollArea>
);
};
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
session, session,
isLoading, isLoading,
@@ -106,16 +244,81 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
}); });
}; };
return ( const handleLaunchInNewWindow = () => {
<div className="h-screen w-full flex flex-col"> if (session) {
<MoreMenuLayout showMenu={false} /> console.log('Launching session in new window:', session.session_id);
console.log('Session details:', session);
<SessionHeaderCard onBack={onBack}> // Get the working directory from the session metadata
<div className="ml-8"> const workingDir = session.metadata?.working_dir;
<h1 className="text-lg text-textStandardInverse">
{session.metadata.description || session.session_id} if (workingDir) {
</h1> console.log(
<div className="flex items-center text-sm text-textSubtle mt-1 space-x-5"> `Opening new window with session ID: ${session.session_id}, in working dir: ${workingDir}`
);
// Create a new chat window with the working directory and session ID
window.electron.createChatWindow(
undefined, // query
workingDir, // dir
undefined, // version
session.session_id // resumeSessionId
);
console.log('createChatWindow called successfully');
} else {
console.error('No working directory found in session metadata');
toast.error('Could not launch session: Missing working directory');
}
}
};
// Define action buttons
const actionButtons = showActionButtons ? (
<>
<Button
onClick={handleShare}
disabled={!canShare || isSharing}
size="sm"
variant="outline"
className={canShare ? '' : 'cursor-not-allowed opacity-50'}
>
{isSharing ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Sharing...
</>
) : (
<>
<Share2 className="w-4 h-4" />
Share
</>
)}
</Button>
<Button onClick={onResume} size="sm" variant="outline">
<Sparkles className="w-4 h-4" />
Resume
</Button>
<Button onClick={handleLaunchInNewWindow} size="sm" variant="outline">
<ExternalLink className="w-4 h-4" />
New Window
</Button>
</>
) : null;
return (
<>
<MainPanelLayout>
<div className="flex-1 flex flex-col min-h-0 px-8">
<SessionHeader
onBack={onBack}
title={session.metadata.description || 'Session Details'}
actionButtons={!isLoading ? actionButtons : null}
>
<div className="flex flex-col">
{!isLoading && session.messages.length > 0 ? (
<>
<div className="flex items-center text-text-muted text-sm space-x-5 font-mono">
<span className="flex items-center"> <span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" /> <Calendar className="w-4 h-4 mr-1" />
{formatMessageTimestamp(session.messages[0]?.created)} {formatMessageTimestamp(session.messages[0]?.created)}
@@ -131,48 +334,21 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
</span> </span>
)} )}
</div> </div>
<div className="flex items-center text-sm text-textSubtle space-x-5"> <div className="flex items-center text-text-muted text-sm mt-1 font-mono">
<span className="flex items-center"> <span className="flex items-center">
<Folder className="w-4 h-4 mr-1" /> <Folder className="w-4 h-4 mr-1" />
{session.metadata.working_dir} {session.metadata.working_dir}
</span> </span>
</div> </div>
</div>
{showActionButtons && (
<div className="ml-auto flex items-center space-x-4">
<button
onClick={handleShare}
title="Share Session"
disabled={!canShare || isSharing}
className={`flex items-center text-textStandardInverse px-2 py-1 ${
canShare
? 'hover:font-bold hover:scale-110 transition-all duration-150'
: 'cursor-not-allowed opacity-50'
}`}
>
{isSharing ? (
<>
<LoaderCircle className="w-7 h-7 animate-spin mr-2" />
<span>Sharing...</span>
</> </>
) : ( ) : (
<> <div className="flex items-center text-text-muted text-sm">
<Share2 className="w-7 h-7" /> <LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
</> <span>Loading session details...</span>
)}
</button>
<button
onClick={onResume}
title="Resume Session"
className="flex items-center text-textStandardInverse px-2 py-1 hover:font-bold hover:scale-110 transition-all duration-150"
>
<Sparkles className="w-7 h-7" />
</button>
</div> </div>
)} )}
</SessionHeaderCard> </div>
</SessionHeader>
<SessionMessages <SessionMessages
messages={session.messages} messages={session.messages}
@@ -180,28 +356,28 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
error={error} error={error}
onRetry={onRetry} onRetry={onRetry}
/> />
</div>
</MainPanelLayout>
<Modal open={isShareModalOpen} onOpenChange={setIsShareModalOpen}> <Dialog open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
<ModalContent className="sm:max-w-md p-0 bg-bgApp dark:bg-bgApp dark:border-borderSubtle"> <DialogContent className="sm:max-w-md">
<div className="flex justify-center mt-4"> <DialogHeader>
<DialogTitle className="flex justify-center items-center gap-2">
<Share2 className="w-6 h-6 text-textStandard" /> <Share2 className="w-6 h-6 text-textStandard" />
</div> Share Session (beta)
</DialogTitle>
<div className="mt-2 px-6 text-center"> <DialogDescription>
<h2 className="text-lg font-semibold text-textStandard">Share Session (beta)</h2>
</div>
<div className="px-6 flex flex-col gap-4 mt-2">
<p className="text-sm text-center text-textSubtle">
Share this session link to give others a read only view of your goose chat. Share this session link to give others a read only view of your goose chat.
</p> </DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="relative rounded-lg border border-borderSubtle px-3 py-2 flex items-center bg-gray-100 dark:bg-gray-600"> <div className="relative rounded-lg border border-borderSubtle px-3 py-2 flex items-center bg-gray-100 dark:bg-gray-600">
<code className="text-sm text-textStandard dark:text-textStandardInverse overflow-x-hidden break-all pr-8 w-full"> <code className="text-sm text-textStandard dark:text-textStandardInverse overflow-x-hidden break-all pr-8 w-full">
{shareLink} {shareLink}
</code> </code>
<Button <Button
size="icon" shape="round"
variant="ghost" variant="ghost"
className="absolute right-2 top-1/2 -translate-y-1/2" className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={handleCopyLink} onClick={handleCopyLink}
@@ -213,19 +389,14 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
</div> </div>
</div> </div>
<div> <DialogFooter>
<Button <Button variant="outline" onClick={() => setIsShareModalOpen(false)}>
type="button"
variant="ghost"
onClick={() => setIsShareModalOpen(false)}
className="w-full h-[60px] border-t rounded-b-lg dark:border-gray-600 text-lg text-textStandard hover:bg-gray-100 hover:dark:bg-gray-600"
>
Cancel Cancel
</Button> </Button>
</div> </DialogFooter>
</ModalContent> </DialogContent>
</Modal> </Dialog>
</div> </>
); );
}; };

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Session } from '../../sessions';
import { Card } from '../ui/card';
import { formatDate } from '../../utils/date';
interface SessionItemProps {
session: Session;
extraActions?: React.ReactNode;
}
const SessionItem: React.FC<SessionItemProps> = ({ session, extraActions }) => {
return (
<Card className="p-4 mb-2 hover:bg-accent/50 cursor-pointer flex justify-between items-center">
<div>
<div className="font-medium">{session.metadata.description || `Session ${session.id}`}</div>
<div className="text-sm text-muted-foreground">
{formatDate(session.modified)} {session.metadata.message_count} messages
</div>
<div className="text-sm text-muted-foreground">{session.metadata.working_dir}</div>
</div>
{extraActions && <div>{extraActions}</div>}
</Card>
);
};
export default SessionItem;

Some files were not shown because too many files have changed in this diff Show More