mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-19 07:04:21 +01:00
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:
3
.github/workflows/bundle-desktop-intel.yml
vendored
3
.github/workflows/bundle-desktop-intel.yml
vendored
@@ -82,7 +82,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
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
|
||||
|
||||
# Update versions before build
|
||||
|
||||
3
.github/workflows/bundle-desktop-linux.yml
vendored
3
.github/workflows/bundle-desktop-linux.yml
vendored
@@ -28,7 +28,8 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
|
||||
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
|
||||
|
||||
# 2) Update versions before build
|
||||
|
||||
3
.github/workflows/bundle-desktop-windows.yml
vendored
3
.github/workflows/bundle-desktop-windows.yml
vendored
@@ -45,7 +45,8 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
|
||||
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
|
||||
|
||||
# 2) Configure AWS credentials for code signing
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod config_management;
|
||||
pub mod context;
|
||||
pub mod extension;
|
||||
pub mod health;
|
||||
pub mod project;
|
||||
pub mod recipe;
|
||||
pub mod reply;
|
||||
pub mod schedule;
|
||||
@@ -27,4 +28,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
|
||||
.merge(recipe::routes(state.clone()))
|
||||
.merge(session::routes(state.clone()))
|
||||
.merge(schedule::routes(state.clone()))
|
||||
.merge(project::routes(state.clone()))
|
||||
}
|
||||
|
||||
358
crates/goose-server/src/routes/project.rs
Normal file
358
crates/goose-server/src/routes/project.rs
Normal 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)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::utils::verify_secret_key;
|
||||
use chrono::{DateTime, Datelike};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::state::AppState;
|
||||
@@ -13,6 +15,7 @@ use goose::session;
|
||||
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
|
||||
use goose::session::SessionMetadata;
|
||||
use serde::Serialize;
|
||||
use tracing::{error, info};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -33,6 +36,29 @@ pub struct SessionHistoryResponse {
|
||||
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(
|
||||
get,
|
||||
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
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/sessions", get(list_sessions))
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod context_mgmt;
|
||||
pub mod message;
|
||||
pub mod model;
|
||||
pub mod permission;
|
||||
pub mod project;
|
||||
pub mod prompt_template;
|
||||
pub mod providers;
|
||||
pub mod recipe;
|
||||
|
||||
68
crates/goose/src/project/mod.rs
Normal file
68
crates/goose/src/project/mod.rs
Normal 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,
|
||||
};
|
||||
239
crates/goose/src/project/storage.rs
Normal file
239
crates/goose/src/project/storage.rs
Normal 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(())
|
||||
}
|
||||
@@ -1267,6 +1267,7 @@ async fn run_scheduled_job_internal(
|
||||
working_dir: current_dir.clone(),
|
||||
description: String::new(),
|
||||
schedule_id: Some(job.id.clone()),
|
||||
project_id: None,
|
||||
message_count: all_session_messages.len(),
|
||||
total_tokens: None,
|
||||
input_tokens: None,
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct SessionMetadata {
|
||||
pub description: String,
|
||||
/// ID of the schedule that triggered this session, if any
|
||||
pub schedule_id: Option<String>,
|
||||
/// ID of the project this session belongs to, if any
|
||||
pub project_id: Option<String>,
|
||||
/// Number of messages in the session
|
||||
pub message_count: usize,
|
||||
/// The total number of tokens used in the session. Retrieved from the provider's last usage.
|
||||
@@ -68,6 +70,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
|
||||
description: String,
|
||||
message_count: usize,
|
||||
schedule_id: Option<String>, // For backward compatibility
|
||||
project_id: Option<String>, // For backward compatibility
|
||||
total_tokens: Option<i32>,
|
||||
input_tokens: Option<i32>,
|
||||
output_tokens: Option<i32>,
|
||||
@@ -89,6 +92,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
|
||||
description: helper.description,
|
||||
message_count: helper.message_count,
|
||||
schedule_id: helper.schedule_id,
|
||||
project_id: helper.project_id,
|
||||
total_tokens: helper.total_tokens,
|
||||
input_tokens: helper.input_tokens,
|
||||
output_tokens: helper.output_tokens,
|
||||
@@ -113,6 +117,7 @@ impl SessionMetadata {
|
||||
working_dir,
|
||||
description: String::new(),
|
||||
schedule_id: None,
|
||||
project_id: None,
|
||||
message_count: 0,
|
||||
total_tokens: None,
|
||||
input_tokens: None,
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct ConfigurableMockScheduler {
|
||||
sessions_data: Arc<Mutex<HashMap<String, Vec<(String, SessionMetadata)>>>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ConfigurableMockScheduler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -404,6 +405,7 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) ->
|
||||
working_dir: PathBuf::from(working_dir),
|
||||
description: "Test session".to_string(),
|
||||
schedule_id: Some("test_job".to_string()),
|
||||
project_id: None,
|
||||
total_tokens: Some(100),
|
||||
input_tokens: Some(50),
|
||||
output_tokens: Some(50),
|
||||
|
||||
@@ -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`)
|
||||
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`
|
||||
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
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ let cfg = {
|
||||
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
|
||||
signingRole: process.env.WINDOW_SIGNING_ROLE,
|
||||
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
|
||||
protocols: [
|
||||
{
|
||||
name: "GooseProtocol",
|
||||
schemes: ["goose"]
|
||||
}
|
||||
name: 'GooseProtocol',
|
||||
schemes: ['goose'],
|
||||
},
|
||||
],
|
||||
// macOS Info.plist extensions for drag-and-drop support
|
||||
extendInfo: {
|
||||
@@ -44,9 +44,9 @@ let cfg = {
|
||||
osxNotarize: {
|
||||
appleId: process.env['APPLE_ID'],
|
||||
appleIdPassword: process.env['APPLE_ID_PASSWORD'],
|
||||
teamId: process.env['APPLE_TEAM_ID']
|
||||
teamId: process.env['APPLE_TEAM_ID'],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env['APPLE_ID'] === undefined) {
|
||||
delete cfg.osxNotarize;
|
||||
@@ -62,12 +62,12 @@ module.exports = {
|
||||
config: {
|
||||
repository: {
|
||||
owner: 'block',
|
||||
name: 'goose'
|
||||
name: 'goose',
|
||||
},
|
||||
prerelease: false,
|
||||
draft: true
|
||||
}
|
||||
}
|
||||
draft: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
makers: [
|
||||
{
|
||||
@@ -76,22 +76,22 @@ module.exports = {
|
||||
config: {
|
||||
arch: process.env.ELECTRON_ARCH === 'x64' ? ['x64'] : ['arm64'],
|
||||
options: {
|
||||
icon: 'src/images/icon.ico'
|
||||
}
|
||||
}
|
||||
icon: 'src/images/icon.ico',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
config: {
|
||||
name: 'Goose',
|
||||
bin: 'Goose'
|
||||
bin: 'Goose',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
config: {
|
||||
name: 'Goose',
|
||||
bin: 'Goose'
|
||||
bin: 'Goose',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -102,17 +102,17 @@ module.exports = {
|
||||
build: [
|
||||
{
|
||||
entry: 'src/main.ts',
|
||||
config: 'vite.main.config.ts',
|
||||
config: 'vite.main.config.mts',
|
||||
},
|
||||
{
|
||||
entry: 'src/preload.ts',
|
||||
config: 'vite.preload.config.ts',
|
||||
config: 'vite.preload.config.mts',
|
||||
},
|
||||
],
|
||||
renderer: [
|
||||
{
|
||||
name: 'main_window',
|
||||
config: 'vite.renderer.config.ts',
|
||||
config: 'vite.renderer.config.mts',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<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>
|
||||
<script>
|
||||
// Initialize theme before any content loads
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"license": {
|
||||
"name": "Apache-2.0"
|
||||
},
|
||||
"version": "1.0.34"
|
||||
"version": "1.0.35"
|
||||
},
|
||||
"paths": {
|
||||
"/agent/tools": {
|
||||
@@ -1564,6 +1564,10 @@
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"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.",
|
||||
"nullable": true
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the project this session belongs to, if any",
|
||||
"nullable": true
|
||||
},
|
||||
"schedule_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the schedule that triggered this session, if any",
|
||||
|
||||
4657
ui/desktop/package-lock.json
generated
4657
ui/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "goose-app",
|
||||
"productName": "Goose",
|
||||
"version": "1.0.36",
|
||||
"version": "1.1.0-alpha.4",
|
||||
"description": "Goose App",
|
||||
"engines": {
|
||||
"node": "^22.9.0"
|
||||
@@ -33,6 +33,55 @@
|
||||
"prepare": "cd ../.. && husky install",
|
||||
"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": {
|
||||
"@electron-forge/cli": "^7.5.0",
|
||||
"@electron-forge/maker-deb": "^7.5.0",
|
||||
@@ -50,11 +99,17 @@
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/electron": "^1.4.38",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/electron-window-state": "^2.0.34",
|
||||
"@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/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
@@ -67,7 +122,7 @@
|
||||
"lint-staged": "^15.4.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "~5.5.0",
|
||||
"vite": "^6.3.4"
|
||||
},
|
||||
@@ -82,53 +137,5 @@
|
||||
"src/**/*.{css,json}": [
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -12,7 +12,7 @@ async function buildMain() {
|
||||
}
|
||||
|
||||
await build({
|
||||
configFile: resolve(__dirname, '../vite.main.config.ts'),
|
||||
configFile: resolve(__dirname, '../vite.main.config.mts'),
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,7 @@ export type ListSchedulesResponse = {
|
||||
export type Message = {
|
||||
content: Array<MessageContent>;
|
||||
created: number;
|
||||
id?: string | null;
|
||||
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.
|
||||
*/
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -2,13 +2,21 @@ interface AgentHeaderProps {
|
||||
title: string;
|
||||
profileInfo?: string;
|
||||
onChangeProfile?: () => void;
|
||||
showBorder?: boolean;
|
||||
}
|
||||
|
||||
export function AgentHeader({ title, profileInfo, onChangeProfile }: AgentHeaderProps) {
|
||||
export function AgentHeader({
|
||||
title,
|
||||
profileInfo,
|
||||
onChangeProfile,
|
||||
showBorder = false,
|
||||
}: AgentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-borderSubtle">
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 rounded-full bg-blockTeal mr-2" />
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-2 ${showBorder ? 'border-b border-borderSubtle' : ''}`}
|
||||
>
|
||||
<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-textSubtle">Agent</span>{' '}
|
||||
<span className="text-textStandard">{title}</span>
|
||||
|
||||
525
ui/desktop/src/components/BaseChat.tsx
Normal file
525
ui/desktop/src/components/BaseChat.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -38,12 +38,7 @@ export function ErrorUI({ error }: { error: Error }) {
|
||||
{error.message}
|
||||
</pre>
|
||||
|
||||
<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>
|
||||
<Button onClick={() => window.electron.reloadApp()}>Reload</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,21 @@ interface FileIconProps {
|
||||
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) {
|
||||
return (
|
||||
<svg 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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +28,20 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
|
||||
// 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 (
|
||||
<svg className={className} viewBox="0 0 24 24" 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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +49,15 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Video files
|
||||
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} 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
|
||||
className={className}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -41,10 +65,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Audio files
|
||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -52,12 +82,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Archive/compressed files
|
||||
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" 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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -65,12 +101,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// PDF files
|
||||
if (ext === 'pdf') {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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 12h4"/>
|
||||
<path d="M10 16h2"/>
|
||||
<path d="M10 8h2"/>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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 12h4" />
|
||||
<path d="M10 16h2" />
|
||||
<path d="M10 8h2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -78,10 +120,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Design files
|
||||
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -89,10 +137,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// JavaScript/TypeScript files
|
||||
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -100,12 +154,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Python files
|
||||
if (['py', 'pyw', 'pyc'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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"/>
|
||||
<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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -113,11 +173,17 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// HTML files
|
||||
if (['html', 'htm', 'xhtml'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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"/>
|
||||
<polyline points="9,13 9,17 15,17 15,13"/>
|
||||
<line x1="12" y1="13" x2="12" y2="17"/>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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" />
|
||||
<polyline points="9,13 9,17 15,17 15,13" />
|
||||
<line x1="12" y1="13" x2="12" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -125,12 +191,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// CSS files
|
||||
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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="M8 13h8"/>
|
||||
<path d="M8 17h8"/>
|
||||
<circle cx="12" cy="15" r="1"/>
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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="M8 13h8" />
|
||||
<path d="M8 17h8" />
|
||||
<circle cx="12" cy="15" r="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -138,11 +210,17 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// JSON/Data files
|
||||
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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="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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
@@ -150,12 +228,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Markdown files
|
||||
if (['md', 'markdown', 'mdx'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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"/>
|
||||
<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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -163,35 +247,68 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Database files
|
||||
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Configuration files
|
||||
if (['env', 'ini', 'cfg', 'conf', 'config', 'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc'].includes(ext || '') ||
|
||||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())) {
|
||||
if (
|
||||
[
|
||||
'env',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'config',
|
||||
'gitignore',
|
||||
'dockerignore',
|
||||
'editorconfig',
|
||||
'prettierrc',
|
||||
'eslintrc',
|
||||
].includes(ext || '') ||
|
||||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())
|
||||
) {
|
||||
return (
|
||||
<svg className={className} 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
|
||||
className={className}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Text files
|
||||
if (['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
|
||||
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())) {
|
||||
if (
|
||||
['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
|
||||
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())
|
||||
) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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"/>
|
||||
<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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -199,10 +316,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Executable files
|
||||
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -210,21 +333,33 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
||||
// Script files
|
||||
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) {
|
||||
return (
|
||||
<svg className={className} 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
|
||||
className={className}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Default file icon
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" 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"/>
|
||||
<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
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
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" />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Goose, Rain } from './icons/Goose';
|
||||
import { cn } from '../utils';
|
||||
|
||||
interface GooseLogoProps {
|
||||
className?: string;
|
||||
@@ -28,12 +29,21 @@ export default function GooseLogo({
|
||||
|
||||
return (
|
||||
<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
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface GooseMessageProps {
|
||||
toolCallNotifications: Map<string, NotificationEvent[]>;
|
||||
append: (value: string) => void;
|
||||
appendMessage: (message: Message) => void;
|
||||
isStreaming?: boolean; // Whether this message is currently being streamed
|
||||
}
|
||||
|
||||
export default function GooseMessage({
|
||||
@@ -39,8 +40,11 @@ export default function GooseMessage({
|
||||
toolCallNotifications,
|
||||
append,
|
||||
appendMessage,
|
||||
isStreaming = false,
|
||||
}: GooseMessageProps) {
|
||||
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
|
||||
let textContent = getTextContent(message);
|
||||
@@ -115,17 +119,29 @@ export default function GooseMessage({
|
||||
if (
|
||||
messageIndex === messageHistoryIndex - 1 &&
|
||||
hasToolConfirmation &&
|
||||
toolConfirmationContent
|
||||
toolConfirmationContent &&
|
||||
!handledToolConfirmations.current.has(toolConfirmationContent.id)
|
||||
) {
|
||||
appendMessage(
|
||||
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
|
||||
// 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(
|
||||
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
messageIndex,
|
||||
messageHistoryIndex,
|
||||
hasToolConfirmation,
|
||||
toolConfirmationContent,
|
||||
messages,
|
||||
appendMessage,
|
||||
]);
|
||||
|
||||
@@ -147,7 +163,7 @@ export default function GooseMessage({
|
||||
{/* Visible assistant response */}
|
||||
{displayText && (
|
||||
<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>
|
||||
|
||||
@@ -160,18 +176,20 @@ export default function GooseMessage({
|
||||
</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">
|
||||
{toolRequests.length === 0 && (
|
||||
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
||||
{toolRequests.length === 0 && !isStreaming && (
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
{displayText && message.content.every((content) => content.type === 'text') && (
|
||||
<div className="absolute left-0 pt-1">
|
||||
<MessageCopyLink text={displayText} contentRef={contentRef} />
|
||||
</div>
|
||||
)}
|
||||
{displayText &&
|
||||
message.content.every((content) => content.type === 'text') &&
|
||||
!isStreaming && (
|
||||
<div className="absolute left-0 pt-1">
|
||||
<MessageCopyLink text={displayText} contentRef={contentRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -179,10 +197,7 @@ export default function GooseMessage({
|
||||
{toolRequests.length > 0 && (
|
||||
<div className="relative flex flex-col w-full">
|
||||
{toolRequests.map((toolRequest) => (
|
||||
<div
|
||||
className={`goose-message-tool bg-bgSubtle rounded px-2 py-2 mb-2`}
|
||||
key={toolRequest.id}
|
||||
>
|
||||
<div className={`goose-message-tool pb-2`} key={toolRequest.id}>
|
||||
<ToolCallWithResponse
|
||||
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
|
||||
isCancelledMessage={
|
||||
@@ -192,11 +207,12 @@ export default function GooseMessage({
|
||||
toolRequest={toolRequest}
|
||||
toolResponse={toolResponsesMap.get(toolRequest.id)}
|
||||
notifications={toolCallNotifications.get(toolRequest.id)}
|
||||
isStreamingMessage={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
||||
{timestamp}
|
||||
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
||||
{!isStreaming && timestamp}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,11 +147,7 @@ export default function GooseResponseForm({
|
||||
<div className="space-y-4">
|
||||
{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">
|
||||
<Button
|
||||
onClick={handleAccept}
|
||||
variant="default"
|
||||
className="w-full sm:w-auto dark:bg-button-dark"
|
||||
>
|
||||
<Button onClick={handleAccept} className="w-full sm:w-auto">
|
||||
<Send className="h-[14px] w-[14px]" />
|
||||
Take flight with this plan
|
||||
</Button>
|
||||
@@ -230,11 +226,7 @@ export default function GooseResponseForm({
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="w-full sm:w-auto mt-4 dark:bg-button-dark"
|
||||
>
|
||||
<Button type="submit" className="w-full sm:w-auto mt-4">
|
||||
<Send className="h-[14px] w-[14px]" />
|
||||
Submit Form
|
||||
</Button>
|
||||
|
||||
196
ui/desktop/src/components/GooseSidebar/AppSidebar.tsx
Normal file
196
ui/desktop/src/components/GooseSidebar/AppSidebar.tsx
Normal 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;
|
||||
356
ui/desktop/src/components/GooseSidebar/SessionsSection.tsx
Normal file
356
ui/desktop/src/components/GooseSidebar/SessionsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
135
ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx
Normal file
135
ui/desktop/src/components/GooseSidebar/ThemeSelector.tsx
Normal 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;
|
||||
1
ui/desktop/src/components/GooseSidebar/index.ts
Normal file
1
ui/desktop/src/components/GooseSidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AppSidebar } from './AppSidebar';
|
||||
@@ -1,25 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Check } from './icons';
|
||||
|
||||
const Modal = ({ children }: { children: React.ReactNode }) => (
|
||||
<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-none px-8 pt-[24px] pb-0">
|
||||
<div className="flex flex-col flex-1 space-y-8 text-base text-textStandard h-full">
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ModalHeader = () => (
|
||||
<div className="space-y-8">
|
||||
<div className="flex">
|
||||
<h2 className="text-2xl font-regular text-textStandard">Configure .goosehints</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
|
||||
const ModalHelpText = () => (
|
||||
<div className="text-sm flex-col space-y-4">
|
||||
@@ -66,27 +55,6 @@ const ModalFileInfo = ({ filePath, found }: { filePath: string; found: boolean }
|
||||
</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);
|
||||
|
||||
type GoosehintsModalProps = {
|
||||
@@ -122,26 +90,45 @@ export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: Goosehi
|
||||
setIsGoosehintsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsGoosehintsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalHeader />
|
||||
<ModalHelpText />
|
||||
<div className="flex flex-col flex-1">
|
||||
{goosehintsFileReadError ? (
|
||||
<ModalError error={new Error(goosehintsFileReadError)} />
|
||||
) : (
|
||||
<div className="flex flex-col flex-1 space-y-2 h-full">
|
||||
<ModalFileInfo filePath={goosehintsFilePath} found={goosehintsFileFound} />
|
||||
<textarea
|
||||
defaultValue={goosehintsFile}
|
||||
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"
|
||||
onChange={(event) => setGoosehintsFile(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ModalButtons onSubmit={writeFile} onCancel={() => setIsGoosehintsModalOpen(false)} />
|
||||
</Modal>
|
||||
<Dialog open={true} onOpenChange={handleClose}>
|
||||
<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 />
|
||||
|
||||
<div className="py-4">
|
||||
{goosehintsFileReadError ? (
|
||||
<ModalError error={new Error(goosehintsFileReadError)} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<ModalFileInfo filePath={goosehintsFilePath} found={goosehintsFileFound} />
|
||||
<textarea
|
||||
defaultValue={goosehintsFile}
|
||||
autoFocus
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={writeFile}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,9 +13,9 @@ export default function LayingEggLoader() {
|
||||
}, []);
|
||||
|
||||
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="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" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-medium text-center mb-2 text-textProminent">
|
||||
|
||||
122
ui/desktop/src/components/Layout/AppLayout.tsx
Normal file
122
ui/desktop/src/components/Layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
18
ui/desktop/src/components/Layout/MainPanelLayout.tsx
Normal file
18
ui/desktop/src/components/Layout/MainPanelLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,18 @@
|
||||
import GooseLogo from './GooseLogo';
|
||||
|
||||
const LoadingGoose = () => {
|
||||
interface LoadingGooseProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const LoadingGoose = ({ message = 'goose is working on it…' }: LoadingGooseProps) => {
|
||||
return (
|
||||
<div className="w-full pb-[2px]">
|
||||
<div className="w-full animate-fade-slide-up">
|
||||
<div
|
||||
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} />
|
||||
goose is working on it…
|
||||
<GooseLogo size="small" hover={false} />
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -112,17 +112,17 @@ export default function MarkdownContent({ content, className = '' }: MarkdownCon
|
||||
<div className="w-full overflow-x-hidden">
|
||||
<ReactMarkdown
|
||||
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-code:break-all prose-code:whitespace-pre-wrap
|
||||
prose-table:table prose-table:w-full
|
||||
prose-blockquote:text-inherit
|
||||
prose-td:border prose-td:border-borderSubtle prose-td:p-2
|
||||
prose-th:border prose-th:border-borderSubtle prose-th:p-2
|
||||
prose-thead:bg-bgSubtle
|
||||
prose-h1:text-2xl prose-h1:font-medium prose-h1:mb-5 prose-h1:mt-5
|
||||
prose-h2:text-xl prose-h2:font-medium 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-td:border prose-td:border-border-default prose-td:p-2
|
||||
prose-th:border prose-th:border-border-default prose-th:p-2
|
||||
prose-thead:bg-background-default
|
||||
prose-h1:text-2xl prose-h1:font-normal prose-h1:mb-5 prose-h1:mt-0
|
||||
prose-h2:text-xl prose-h2:font-normal prose-h2:mb-4 prose-h2:mt-4
|
||||
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-ol:my-2
|
||||
prose-ul:mt-0 prose-ul:mb-3
|
||||
|
||||
@@ -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';
|
||||
|
||||
interface FileItem {
|
||||
@@ -46,7 +54,13 @@ const fuzzyMatch = (pattern: string, text: string): { score: number; matches: nu
|
||||
score += consecutiveMatches * 3;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -85,15 +99,7 @@ const fuzzyMatch = (pattern: string, text: string): { score: number; matches: nu
|
||||
const MentionPopover = forwardRef<
|
||||
{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void },
|
||||
MentionPopoverProps
|
||||
>(({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
position,
|
||||
query,
|
||||
selectedIndex,
|
||||
onSelectedIndexChange
|
||||
}, ref) => {
|
||||
>(({ isOpen, onClose, onSelect, position, query, selectedIndex, onSelectedIndexChange }, ref) => {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -102,16 +108,16 @@ const MentionPopover = forwardRef<
|
||||
// Filter and sort files based on query
|
||||
const displayFiles = useMemo((): FileItemWithMatch[] => {
|
||||
if (!query.trim()) {
|
||||
return files.slice(0, 15).map(file => ({
|
||||
return files.slice(0, 15).map((file) => ({
|
||||
...file,
|
||||
matchScore: 0,
|
||||
matches: [],
|
||||
matchedText: file.name
|
||||
matchedText: file.name,
|
||||
})); // Show first 15 files when no query
|
||||
}
|
||||
|
||||
const results = files
|
||||
.map(file => {
|
||||
.map((file) => {
|
||||
const nameMatch = fuzzyMatch(query, file.name);
|
||||
const pathMatch = fuzzyMatch(query, file.relativePath);
|
||||
const fullPathMatch = fuzzyMatch(query, file.path);
|
||||
@@ -134,10 +140,10 @@ const MentionPopover = forwardRef<
|
||||
...file,
|
||||
matchScore: bestMatch.score,
|
||||
matches: bestMatch.matches,
|
||||
matchedText
|
||||
matchedText,
|
||||
};
|
||||
})
|
||||
.filter(file => file.matchScore > 0)
|
||||
.filter((file) => file.matchScore > 0)
|
||||
.sort((a, b) => {
|
||||
// Sort by score first, then prefer files over directories, then alphabetically
|
||||
if (Math.abs(a.matchScore - b.matchScore) < 1) {
|
||||
@@ -154,15 +160,19 @@ const MentionPopover = forwardRef<
|
||||
}, [files, query]);
|
||||
|
||||
// Expose methods to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
getDisplayFiles: () => displayFiles,
|
||||
selectFile: (index: number) => {
|
||||
if (displayFiles[index]) {
|
||||
onSelect(displayFiles[index].path);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}), [displayFiles, onSelect, onClose]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getDisplayFiles: () => displayFiles,
|
||||
selectFile: (index: number) => {
|
||||
if (displayFiles[index]) {
|
||||
onSelect(displayFiles[index].path);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[displayFiles, onSelect, onClose]
|
||||
);
|
||||
|
||||
// Scan files when component opens
|
||||
useEffect(() => {
|
||||
@@ -189,119 +199,215 @@ const MentionPopover = forwardRef<
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const scanDirectoryFromRoot = useCallback(async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
|
||||
// Increase depth limit for better file discovery
|
||||
if (depth > 5) return [];
|
||||
const scanDirectoryFromRoot = useCallback(
|
||||
async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
|
||||
// Increase depth limit for better file discovery
|
||||
if (depth > 5) return [];
|
||||
|
||||
try {
|
||||
const items = await window.electron.listFiles(dirPath);
|
||||
const results: FileItem[] = [];
|
||||
try {
|
||||
const items = await window.electron.listFiles(dirPath);
|
||||
const results: FileItem[] = [];
|
||||
|
||||
// Common directories to prioritize or skip
|
||||
const priorityDirs = ['Desktop', 'Documents', 'Downloads', 'Projects', 'Development', 'Code', 'src', 'components', 'icons'];
|
||||
const skipDirs = [
|
||||
'.git', '.svn', '.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
|
||||
const skipDirsAtDepth = depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
|
||||
|
||||
// Sort items to prioritize certain directories
|
||||
const sortedItems = items.sort((a, b) => {
|
||||
const aPriority = priorityDirs.includes(a);
|
||||
const bPriority = priorityDirs.includes(b);
|
||||
if (aPriority && !bPriority) return -1;
|
||||
if (!aPriority && bPriority) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Increase item limit per directory for better coverage
|
||||
const itemLimit = depth === 0 ? 50 : depth === 1 ? 40 : 30;
|
||||
|
||||
for (const item of sortedItems.slice(0, itemLimit)) {
|
||||
const fullPath = `${dirPath}/${item}`;
|
||||
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
|
||||
|
||||
// Skip hidden files and common ignore patterns
|
||||
if (item.startsWith('.') || skipDirsAtDepth.includes(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First, check if this looks like a file based on extension
|
||||
const hasExtension = item.includes('.');
|
||||
const ext = item.split('.').pop()?.toLowerCase();
|
||||
const commonExtensions = [
|
||||
// Code files
|
||||
'txt', 'md', 'js', '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
|
||||
'readme', 'license', 'changelog', 'contributing',
|
||||
// Config files
|
||||
'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc',
|
||||
// Images and assets
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif',
|
||||
// Vector and design files
|
||||
'ai', 'eps', 'sketch', 'fig', 'xd', 'psd',
|
||||
// Other common files
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'
|
||||
// Common directories to prioritize or skip
|
||||
const priorityDirs = [
|
||||
'Desktop',
|
||||
'Documents',
|
||||
'Downloads',
|
||||
'Projects',
|
||||
'Development',
|
||||
'Code',
|
||||
'src',
|
||||
'components',
|
||||
'icons',
|
||||
];
|
||||
const skipDirs = [
|
||||
'.git',
|
||||
'.svn',
|
||||
'.hg',
|
||||
'node_modules',
|
||||
'__pycache__',
|
||||
'.vscode',
|
||||
'.idea',
|
||||
'target',
|
||||
'dist',
|
||||
'build',
|
||||
'.cache',
|
||||
'.npm',
|
||||
'.yarn',
|
||||
'Library',
|
||||
'System',
|
||||
'Applications',
|
||||
'.Trash',
|
||||
];
|
||||
|
||||
// If it has a known file extension, treat it as a file
|
||||
if (hasExtension && ext && commonExtensions.includes(ext)) {
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: false,
|
||||
relativePath: itemRelativePath
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Don't skip as many directories at deeper levels to find more files
|
||||
const skipDirsAtDepth =
|
||||
depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
|
||||
|
||||
// If it's a known file without extension (README, LICENSE, etc.)
|
||||
const knownFiles = ['readme', 'license', 'changelog', 'contributing', 'dockerfile', 'makefile'];
|
||||
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: false,
|
||||
relativePath: itemRelativePath
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Sort items to prioritize certain directories
|
||||
const sortedItems = items.sort((a, b) => {
|
||||
const aPriority = priorityDirs.includes(a);
|
||||
const bPriority = priorityDirs.includes(b);
|
||||
if (aPriority && !bPriority) return -1;
|
||||
if (!aPriority && bPriority) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Otherwise, try to determine if it's a directory
|
||||
try {
|
||||
await window.electron.listFiles(fullPath);
|
||||
// Increase item limit per directory for better coverage
|
||||
const itemLimit = depth === 0 ? 50 : depth === 1 ? 40 : 30;
|
||||
|
||||
// It's a directory
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: true,
|
||||
relativePath: itemRelativePath
|
||||
});
|
||||
for (const item of sortedItems.slice(0, itemLimit)) {
|
||||
const fullPath = `${dirPath}/${item}`;
|
||||
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
|
||||
|
||||
// Recursively scan directories more aggressively
|
||||
if (depth < 4 || priorityDirs.includes(item)) {
|
||||
const subFiles = await scanDirectoryFromRoot(fullPath, itemRelativePath, depth + 1);
|
||||
results.push(...subFiles);
|
||||
// Skip hidden files and common ignore patterns
|
||||
if (item.startsWith('.') || skipDirsAtDepth.includes(item)) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// If we can't list it and it doesn't have a known extension, skip it
|
||||
// This could be a file with an unknown extension or a permission issue
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Error scanning directory ${dirPath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
// First, check if this looks like a file based on extension
|
||||
const hasExtension = item.includes('.');
|
||||
const ext = item.split('.').pop()?.toLowerCase();
|
||||
const commonExtensions = [
|
||||
// Code files
|
||||
'txt',
|
||||
'md',
|
||||
'js',
|
||||
'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
|
||||
'readme',
|
||||
'license',
|
||||
'changelog',
|
||||
'contributing',
|
||||
// Config files
|
||||
'gitignore',
|
||||
'dockerignore',
|
||||
'editorconfig',
|
||||
'prettierrc',
|
||||
'eslintrc',
|
||||
// Images and assets
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'svg',
|
||||
'ico',
|
||||
'webp',
|
||||
'bmp',
|
||||
'tiff',
|
||||
'tif',
|
||||
// Vector and design files
|
||||
'ai',
|
||||
'eps',
|
||||
'sketch',
|
||||
'fig',
|
||||
'xd',
|
||||
'psd',
|
||||
// Other common files
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
];
|
||||
|
||||
// If it has a known file extension, treat it as a file
|
||||
if (hasExtension && ext && commonExtensions.includes(ext)) {
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: false,
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// If it's a known file without extension (README, LICENSE, etc.)
|
||||
const knownFiles = [
|
||||
'readme',
|
||||
'license',
|
||||
'changelog',
|
||||
'contributing',
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
];
|
||||
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: false,
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, try to determine if it's a directory
|
||||
try {
|
||||
await window.electron.listFiles(fullPath);
|
||||
|
||||
// It's a directory
|
||||
results.push({
|
||||
path: fullPath,
|
||||
name: item,
|
||||
isDirectory: true,
|
||||
relativePath: itemRelativePath,
|
||||
});
|
||||
|
||||
// Recursively scan directories more aggressively
|
||||
if (depth < 4 || priorityDirs.includes(item)) {
|
||||
const subFiles = await scanDirectoryFromRoot(fullPath, itemRelativePath, depth + 1);
|
||||
results.push(...subFiles);
|
||||
}
|
||||
} catch {
|
||||
// If we can't list it and it doesn't have a known extension, skip it
|
||||
// This could be a file with an unknown extension or a permission issue
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Error scanning directory ${dirPath}:`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const scanFilesFromRoot = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -348,7 +454,7 @@ const MentionPopover = forwardRef<
|
||||
return (
|
||||
<div
|
||||
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={{
|
||||
left: position.x,
|
||||
top: position.y - 10, // Position above the chat input
|
||||
@@ -378,12 +484,8 @@ const MentionPopover = forwardRef<
|
||||
<FileIcon fileName={file.name} isDirectory={file.isDirectory} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate text-textStandard">
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-xs text-textSubtle truncate">
|
||||
{file.path}
|
||||
</div>
|
||||
<div className="text-sm truncate text-textStandard">{file.name}</div>
|
||||
<div className="text-xs text-textSubtle truncate">{file.path}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkPro
|
||||
return (
|
||||
<button
|
||||
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" />
|
||||
<span>{copied ? 'Copied!' : 'Copy'}</span>
|
||||
|
||||
@@ -69,11 +69,11 @@ export default function Modal({
|
||||
>
|
||||
<Card
|
||||
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>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { toastError, toastSuccess } from '../toasts';
|
||||
import Model, { getProviderMetadata } from './settings/models/modelInterface';
|
||||
import { ProviderMetadata } from '../api';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import {
|
||||
getModelDisplayName,
|
||||
getProviderDisplayName,
|
||||
} from './settings/models/predefinedModelsUtils';
|
||||
|
||||
// titles
|
||||
export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup';
|
||||
@@ -26,6 +30,8 @@ interface ModelAndProviderContextType {
|
||||
getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>;
|
||||
getFallbackModelAndProvider: () => 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>;
|
||||
}
|
||||
|
||||
@@ -138,6 +144,30 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
||||
return { model: gooseModel, provider: providerDisplayName };
|
||||
}, [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 () => {
|
||||
try {
|
||||
const { model, provider } = await getCurrentModelAndProvider();
|
||||
@@ -161,6 +191,8 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
||||
getCurrentModelAndProvider,
|
||||
getFallbackModelAndProvider,
|
||||
getCurrentModelAndProviderForDisplay,
|
||||
getCurrentModelDisplayName,
|
||||
getCurrentProviderDisplayName,
|
||||
refreshCurrentModelAndProvider,
|
||||
}),
|
||||
[
|
||||
@@ -170,6 +202,8 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
||||
getCurrentModelAndProvider,
|
||||
getFallbackModelAndProvider,
|
||||
getCurrentModelAndProviderForDisplay,
|
||||
getCurrentModelDisplayName,
|
||||
getCurrentProviderDisplayName,
|
||||
refreshCurrentModelAndProvider,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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]">
|
||||
{showCancelOptions ? (
|
||||
// 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>
|
||||
<p className="text-textStandard mb-6">What would you like to do?</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -113,7 +113,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
// 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>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{parameters.map((param) => (
|
||||
|
||||
76
ui/desktop/src/components/PopularChatTopics.tsx
Normal file
76
ui/desktop/src/components/PopularChatTopics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
ui/desktop/src/components/ProgressiveMessageList.tsx
Normal file
274
ui/desktop/src/components/ProgressiveMessageList.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
ui/desktop/src/components/ProviderGuard.tsx
Normal file
54
ui/desktop/src/components/ProviderGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export default function RecipeActivityEditor({
|
||||
activities,
|
||||
@@ -31,16 +32,18 @@ export default function RecipeActivityEditor({
|
||||
{activities.map((activity, index) => (
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
|
||||
<button
|
||||
<Button
|
||||
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>
|
||||
@@ -50,15 +53,15 @@ export default function RecipeActivityEditor({
|
||||
value={newActivity}
|
||||
onChange={(e) => setNewActivity(e.target.value)}
|
||||
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..."
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Recipe } from '../recipe';
|
||||
import { Parameter } from '../recipe/index';
|
||||
|
||||
@@ -16,6 +17,7 @@ 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 RecipeEditorProps {
|
||||
config?: Recipe;
|
||||
@@ -30,6 +32,7 @@ function generateDeepLink(recipe: Recipe): string {
|
||||
|
||||
export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
const { getExtensions } = useConfig();
|
||||
const navigate = useNavigate();
|
||||
const [recipeConfig] = useState<Recipe | undefined>(config);
|
||||
const [title, setTitle] = useState(config?.title || '');
|
||||
const [description, setDescription] = useState(config?.description || '');
|
||||
@@ -321,10 +324,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
}
|
||||
|
||||
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' && (
|
||||
<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" />
|
||||
</div>
|
||||
<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 });
|
||||
}
|
||||
}}
|
||||
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'
|
||||
}`}
|
||||
placeholder="Agent Recipe Title (required)"
|
||||
@@ -372,7 +375,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
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'
|
||||
}`}
|
||||
placeholder="Description (required)"
|
||||
@@ -420,19 +423,21 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
</div>
|
||||
|
||||
{/* 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() ? (
|
||||
<div className="text-sm text-textSubtle text-xs 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 text-xs text-textSubtle">
|
||||
<div className="flex items-center justify-between mb-2 gap-4">
|
||||
<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
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
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 ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
@@ -442,15 +447,18 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
<span className="ml-1 text-sm text-textSubtle">
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{requiredFieldsAreFilled() && (
|
||||
<div
|
||||
onClick={() => validateForm() && handleCopy()}
|
||||
className={`text-sm truncate dark:text-white font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
|
||||
>
|
||||
{deeplink}
|
||||
<div className="w-full overflow-hidden">
|
||||
<div
|
||||
onClick={() => validateForm() && handleCopy()}
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -465,24 +473,27 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Saving...' : 'Save Recipe'}
|
||||
</button>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setIsScheduleModalOpen(true)}
|
||||
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"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Create Schedule
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,24 +510,16 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
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
|
||||
// Navigate to the schedules view with the deep link pre-filled
|
||||
localStorage.setItem('pendingScheduleDeepLink', deepLink);
|
||||
navigate('/schedules');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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]">
|
||||
<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">Save Recipe</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -532,7 +535,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
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"
|
||||
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
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface RecipeExpandableInfoProps {
|
||||
infoLabel: string;
|
||||
@@ -38,7 +39,7 @@ export default function RecipeExpandableInfo({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-lg bg-bgApp text-textStandard">
|
||||
<div className="relative rounded-lg bg-background-default text-textStandard">
|
||||
{infoValue && (
|
||||
<>
|
||||
<div
|
||||
@@ -60,25 +61,27 @@ export default function RecipeExpandableInfo({
|
||||
</>
|
||||
)}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setValueExpanded(true);
|
||||
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()}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{infoValue && isClamped && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
onClick={() => setValueExpanded(!isValueExpanded)}
|
||||
aria-label={isValueExpanded ? 'Collapse content' : 'Expand content'}
|
||||
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
|
||||
className={`w-6 h-6 transition-transform duration-300 ${
|
||||
@@ -86,7 +89,7 @@ export default function RecipeExpandableInfo({
|
||||
}`}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,14 +34,14 @@ export default function RecipeInfoModal({
|
||||
if (!isOpen) return null;
|
||||
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]">
|
||||
<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">
|
||||
<h2 className="text-xl font-semibold text-textProminent">Edit {infoLabel}</h2>
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow overflow-y-auto space-y-8">
|
||||
<textarea
|
||||
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}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={`Enter ${infoLabel.toLowerCase()}...`}
|
||||
|
||||
@@ -6,37 +6,80 @@ import {
|
||||
saveRecipe,
|
||||
generateRecipeFilename,
|
||||
} 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 BackButton from './ui/BackButton';
|
||||
import MoreMenuLayout from './more_menu/MoreMenuLayout';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||
import { Recipe } from '../recipe';
|
||||
import { Buffer } from 'buffer';
|
||||
import { toastSuccess, toastError } from '../toasts';
|
||||
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [importDeeplink, setImportDeeplink] = useState('');
|
||||
const [importRecipeName, setImportRecipeName] = useState('');
|
||||
const [importGlobal, setImportGlobal] = useState(true);
|
||||
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(() => {
|
||||
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 () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowSkeleton(true);
|
||||
setShowContent(false);
|
||||
setError(null);
|
||||
const recipes = await listSavedRecipes();
|
||||
setSavedRecipes(recipes);
|
||||
@@ -50,15 +93,20 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
|
||||
const handleLoadRecipe = async (savedRecipe: SavedRecipe) => {
|
||||
try {
|
||||
// Use the recipe directly - no need for manual mapping
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
undefined, // dir
|
||||
undefined, // version
|
||||
undefined, // resumeSessionId
|
||||
savedRecipe.recipe, // recipe config
|
||||
undefined // view type
|
||||
);
|
||||
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(
|
||||
undefined, // query
|
||||
undefined, // dir
|
||||
undefined, // version
|
||||
undefined, // resumeSessionId
|
||||
savedRecipe.recipe, // recipe config
|
||||
undefined // view type
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load recipe:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load recipe');
|
||||
@@ -186,142 +234,302 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-borderProminent"></div>
|
||||
<p className="mt-4 text-textSubtle">Loading recipes...</p>
|
||||
</div>
|
||||
</div>
|
||||
// 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:
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadSavedRecipes}
|
||||
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90"
|
||||
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"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<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 (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<div className="space-y-2">
|
||||
<RecipeSkeleton />
|
||||
<RecipeSkeleton />
|
||||
<RecipeSkeleton />
|
||||
</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 Recipes</p>
|
||||
<p className="text-sm text-center mb-4">{error}</p>
|
||||
<Button onClick={loadSavedRecipes} variant="default">
|
||||
Try Again
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{savedRecipes.map((savedRecipe) => (
|
||||
<RecipeItem
|
||||
key={`${savedRecipe.isGlobal ? 'global' : 'local'}-${savedRecipe.name}`}
|
||||
savedRecipe={savedRecipe}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex flex-col pb-24">
|
||||
<div className="px-8 pt-6 pb-4">
|
||||
<BackButton onClick={onBack} />
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<h1 className="text-3xl font-medium text-textStandard">Saved Recipes</h1>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Import Recipe
|
||||
</button>
|
||||
<>
|
||||
<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">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}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Import Recipe
|
||||
</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>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 pt-[20px]">
|
||||
{savedRecipes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-8">
|
||||
<FileText className="w-16 h-16 text-textSubtle mb-4" />
|
||||
<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 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>
|
||||
) : (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</MainPanelLayout>
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreview && selectedRecipe && (
|
||||
<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-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto">
|
||||
<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-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-medium text-textStandard">
|
||||
<h3 className="text-xl font-medium text-text-standard">
|
||||
{selectedRecipe.recipe.title}
|
||||
</h3>
|
||||
<p className="text-sm text-textSubtle">
|
||||
<p className="text-sm text-text-muted">
|
||||
{selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
@@ -329,15 +537,15 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-textStandard mb-2">Description</h4>
|
||||
<p className="text-textSubtle">{selectedRecipe.recipe.description}</p>
|
||||
<h4 className="text-sm font-medium text-text-standard mb-2">Description</h4>
|
||||
<p className="text-text-muted">{selectedRecipe.recipe.description}</p>
|
||||
</div>
|
||||
|
||||
{selectedRecipe.recipe.instructions && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-textStandard mb-2">Instructions</h4>
|
||||
<div className="bg-bgSubtle border border-borderSubtle p-3 rounded-lg">
|
||||
<pre className="text-sm text-textSubtle whitespace-pre-wrap font-mono">
|
||||
<h4 className="text-sm font-medium text-text-standard mb-2">Instructions</h4>
|
||||
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
|
||||
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
|
||||
{selectedRecipe.recipe.instructions}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -346,9 +554,9 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
|
||||
{selectedRecipe.recipe.prompt && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-textStandard mb-2">Initial Prompt</h4>
|
||||
<div className="bg-bgSubtle border border-borderSubtle p-3 rounded-lg">
|
||||
<pre className="text-sm text-textSubtle whitespace-pre-wrap font-mono">
|
||||
<h4 className="text-sm font-medium text-text-standard mb-2">Initial Prompt</h4>
|
||||
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
|
||||
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
|
||||
{selectedRecipe.recipe.prompt}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -357,12 +565,12 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
|
||||
{selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && (
|
||||
<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">
|
||||
{selectedRecipe.recipe.activities.map((activity, index) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@@ -372,22 +580,19 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-borderSubtle">
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
|
||||
>
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-border-subtle">
|
||||
<Button onClick={() => setShowPreview(false)} variant="ghost">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowPreview(false);
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,15 +600,15 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
|
||||
{/* Import Recipe Dialog */}
|
||||
{showImportDialog && (
|
||||
<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-[500px] max-w-[90vw]">
|
||||
<h3 className="text-lg font-medium text-textProminent mb-4">Import Recipe</h3>
|
||||
<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-[500px] max-w-[90vw]">
|
||||
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
@@ -411,12 +616,12 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
id="import-deeplink"
|
||||
value={importDeeplink}
|
||||
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"
|
||||
rows={3}
|
||||
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="
|
||||
</p>
|
||||
</div>
|
||||
@@ -424,7 +629,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
<div>
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
@@ -433,13 +638,13 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
type="text"
|
||||
value={importRecipeName}
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
@@ -451,7 +656,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
onChange={() => setImportGlobal(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-textStandard">
|
||||
<span className="text-sm text-text-standard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
@@ -463,7 +668,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
onChange={() => setImportGlobal(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-textStandard">
|
||||
<span className="text-sm text-text-standard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
@@ -472,28 +677,211 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowImportDialog(false);
|
||||
setImportDeeplink('');
|
||||
setImportRecipeName('');
|
||||
}}
|
||||
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
|
||||
variant="ghost"
|
||||
disabled={importing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportRecipe}
|
||||
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'}
|
||||
</button>
|
||||
</Button>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
// Register GSAP plugins
|
||||
gsap.registerPlugin();
|
||||
|
||||
interface SplashProps {
|
||||
append: (text: string) => void;
|
||||
activities: string[] | null;
|
||||
@@ -8,31 +13,65 @@ interface 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 (
|
||||
<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 h-full">
|
||||
<div className="flex flex-col">
|
||||
{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="text-sm">
|
||||
<span className="text-textSubtle">Agent</span>{' '}
|
||||
<span className="text-textStandard">{title}</span>
|
||||
<span className="text-text-muted">Agent</span>{' '}
|
||||
<span className="text-text-default">{title}</span>
|
||||
</span>
|
||||
</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>
|
||||
<SplashPills append={append} activities={activities} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Compact greeting section */}
|
||||
<div className="flex flex-col px-6 mb-0">
|
||||
<Greeting className="text-text-prominent text-4xl font-light mb-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
|
||||
|
||||
if (!needsExpansion) {
|
||||
return (
|
||||
<div className="text-sm mb-2">
|
||||
<div className="text-xs mb-2">
|
||||
<div className="flex flex-row">
|
||||
<span className="text-textSubtle min-w-[140px]">{key}</span>
|
||||
<span className="text-textPlaceholder">{value}</span>
|
||||
@@ -78,9 +78,11 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div className="flex flex-row">
|
||||
<span className="mr- text-textPlaceholder min-w-[140px]2">{key}:</span>
|
||||
<pre className="whitespace-pre-wrap text-textPlaceholder">{content}</pre>
|
||||
<div className="flex flex-row text-xs">
|
||||
<span className="text-textSubtle min-w-[140px]">{key}</span>
|
||||
<pre className="whitespace-pre-wrap text-textPlaceholder overflow-x-auto max-w-full">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { snakeToTitleCase } from '../utils';
|
||||
import PermissionModal from './settings/permission/PermissionModal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { confirmPermission } from '../api';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const ALWAYS_ALLOW = 'always_allow';
|
||||
const ALLOW_ONCE = 'allow_once';
|
||||
@@ -58,16 +59,16 @@ export default function ToolConfirmation({
|
||||
}
|
||||
|
||||
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.
|
||||
</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?
|
||||
</div>
|
||||
{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">
|
||||
{status === 'always_allow' && (
|
||||
<svg
|
||||
@@ -118,31 +119,24 @@ export default function ToolConfirmation({
|
||||
</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">
|
||||
<button
|
||||
className={
|
||||
'bg-black text-white dark:bg-white dark:text-black rounded-full px-6 py-2 transition'
|
||||
}
|
||||
onClick={() => handleButtonClick(ALWAYS_ALLOW)}
|
||||
>
|
||||
<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 className="rounded-full" onClick={() => handleButtonClick(ALWAYS_ALLOW)}>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
'bg-bgProminent text-white dark:text-white rounded-full px-6 py-2 transition'
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full"
|
||||
variant="secondary"
|
||||
onClick={() => handleButtonClick(ALLOW_ONCE)}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
'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'
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full"
|
||||
variant="outline"
|
||||
onClick={() => handleButtonClick(DENY)}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
|
||||
import { snakeToTitleCase } from '../utils';
|
||||
import { cn, snakeToTitleCase } from '../utils';
|
||||
import Dot, { LoadingStatus } from './ui/Dot';
|
||||
import Expand from './ui/Expand';
|
||||
import { NotificationEvent } from '../hooks/useMessageStream';
|
||||
import { ChevronRight, LoaderCircle } from 'lucide-react';
|
||||
|
||||
interface ToolCallWithResponseProps {
|
||||
isCancelledMessage: boolean;
|
||||
toolRequest: ToolRequestMessageContent;
|
||||
toolResponse?: ToolResponseMessageContent;
|
||||
notifications?: NotificationEvent[];
|
||||
isStreamingMessage?: boolean;
|
||||
}
|
||||
|
||||
export default function ToolCallWithResponse({
|
||||
@@ -20,6 +21,7 @@ export default function ToolCallWithResponse({
|
||||
toolRequest,
|
||||
toolResponse,
|
||||
notifications,
|
||||
isStreamingMessage = false,
|
||||
}: ToolCallWithResponseProps) {
|
||||
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
|
||||
if (!toolCall) {
|
||||
@@ -27,10 +29,14 @@ export default function ToolCallWithResponse({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'w-full text-textSubtle text-sm'}>
|
||||
<Card className="">
|
||||
<ToolCallView {...{ isCancelledMessage, toolCall, toolResponse, notifications }} />
|
||||
</Card>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full text-sm rounded-lg overflow-hidden border-borderSubtle border bg-background-muted'
|
||||
)}
|
||||
>
|
||||
<ToolCallView
|
||||
{...{ isCancelledMessage, toolCall, toolResponse, notifications, isStreamingMessage }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,10 +65,19 @@ function ToolCallExpandable({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button onClick={toggleExpand} className="w-full flex justify-between items-center pr-2">
|
||||
<span className="flex items-center">{label}</span>
|
||||
<Expand size={5} isExpanded={isExpanded} />
|
||||
</button>
|
||||
<Button
|
||||
onClick={toggleExpand}
|
||||
className="group w-full flex justify-between items-center pr-2 transition-colors rounded-none"
|
||||
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>}
|
||||
</div>
|
||||
);
|
||||
@@ -76,6 +91,7 @@ interface ToolCallViewProps {
|
||||
};
|
||||
toolResponse?: ToolResponseMessageContent;
|
||||
notifications?: NotificationEvent[];
|
||||
isStreamingMessage?: boolean;
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
@@ -110,8 +126,28 @@ function ToolCallView({
|
||||
toolCall,
|
||||
toolResponse,
|
||||
notifications,
|
||||
isStreamingMessage = false,
|
||||
}: 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 = (() => {
|
||||
switch (responseStyle) {
|
||||
case 'concise':
|
||||
@@ -123,9 +159,27 @@ function ToolCallView({
|
||||
})();
|
||||
|
||||
const isToolDetails = Object.entries(toolCall?.arguments).length > 0;
|
||||
const loadingStatus: LoadingStatus = !toolResponse?.toolResult.status
|
||||
? 'loading'
|
||||
: toolResponse?.toolResult.status;
|
||||
|
||||
// Check if streaming has finished but no tool response was received
|
||||
// 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 }[] =
|
||||
loadingStatus === 'success' && Array.isArray(toolResponse?.toolResult.value)
|
||||
@@ -134,10 +188,17 @@ function ToolCallView({
|
||||
const audience = item.annotations?.audience as string[] | undefined;
|
||||
return !audience || audience.includes('user');
|
||||
})
|
||||
.map((item) => ({
|
||||
result: item,
|
||||
isExpandToolResults: ((item.annotations?.priority as number | undefined) ?? -1) >= 0.5,
|
||||
}))
|
||||
.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,
|
||||
isExpandToolResults: isHighPriority || shouldExpandBasedOnStyle,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const logs = notifications
|
||||
@@ -163,11 +224,19 @@ function ToolCallView({
|
||||
const isRenderingProgress =
|
||||
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
|
||||
const isShouldExpand = toolResults.some((v) => v.isExpandToolResults);
|
||||
// Determine if the main tool call should be expanded
|
||||
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
|
||||
const getToolDescription = () => {
|
||||
const getToolDescription = (): string | null => {
|
||||
const args = toolCall.arguments as Record<string, ToolCallArgumentValue>;
|
||||
const toolName = toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2);
|
||||
|
||||
@@ -318,8 +387,14 @@ function ToolCallView({
|
||||
isForceExpand={isShouldExpand}
|
||||
label={
|
||||
<>
|
||||
<Dot size={2} loadingStatus={loadingStatus} />
|
||||
<span className="ml-[10px]">
|
||||
<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} />
|
||||
)}
|
||||
</div>
|
||||
<span className="ml-2">
|
||||
{(() => {
|
||||
const description = getToolDescription();
|
||||
if (description) {
|
||||
@@ -334,17 +409,19 @@ function ToolCallView({
|
||||
>
|
||||
{/* Tool Details */}
|
||||
{isToolDetails && (
|
||||
<div className="bg-bgStandard rounded-t mt-1">
|
||||
<div className="border-t border-borderSubtle">
|
||||
<ToolDetailsView toolCall={toolCall} isStartExpanded={isExpandToolDetails} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs && logs.length > 0 && (
|
||||
<div className="bg-bgStandard mt-1">
|
||||
<div className="border-t border-borderSubtle">
|
||||
<ToolLogsView
|
||||
logs={logs}
|
||||
working={toolResults.length === 0}
|
||||
isStartExpanded={toolResults.length === 0}
|
||||
working={loadingStatus === 'loading'}
|
||||
isStartExpanded={
|
||||
loadingStatus === 'loading' || responseStyle === 'detailed' || responseStyle === null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -352,7 +429,7 @@ function ToolCallView({
|
||||
{toolResults.length === 0 &&
|
||||
progressEntries.length > 0 &&
|
||||
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} />
|
||||
</div>
|
||||
))}
|
||||
@@ -361,15 +438,8 @@ function ToolCallView({
|
||||
{!isCancelledMessage && (
|
||||
<>
|
||||
{toolResults.map(({ result, isExpandToolResults }, index) => {
|
||||
const isLast = index === toolResults.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-bgStandard mt-1
|
||||
${isToolDetails || index > 0 ? '' : 'rounded-t'}
|
||||
${isLast ? 'rounded-b' : ''}
|
||||
`}
|
||||
>
|
||||
<div key={index} className={cn('border-t border-borderSubtle')}>
|
||||
<ToolResultView result={result} isStartExpanded={isExpandToolResults} />
|
||||
</div>
|
||||
);
|
||||
@@ -391,13 +461,14 @@ interface ToolDetailsViewProps {
|
||||
function ToolDetailsView({ toolCall, isStartExpanded }: ToolDetailsViewProps) {
|
||||
return (
|
||||
<ToolCallExpandable
|
||||
label="Tool Details"
|
||||
className="pl-[19px] py-1"
|
||||
label={<span className="pl-4 font-medium">Tool Details</span>}
|
||||
isStartExpanded={isStartExpanded}
|
||||
>
|
||||
{toolCall.arguments && (
|
||||
<ToolCallArguments args={toolCall.arguments as Record<string, ToolCallArgumentValue>} />
|
||||
)}
|
||||
<div className="pr-4 pl-8">
|
||||
{toolCall.arguments && (
|
||||
<ToolCallArguments args={toolCall.arguments as Record<string, ToolCallArgumentValue>} />
|
||||
)}
|
||||
</div>
|
||||
</ToolCallExpandable>
|
||||
);
|
||||
}
|
||||
@@ -410,14 +481,14 @@ interface ToolResultViewProps {
|
||||
function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
|
||||
return (
|
||||
<ToolCallExpandable
|
||||
label={<span className="pl-[19px] py-1">Output</span>}
|
||||
label={<span className="pl-4 py-1 font-medium">Output</span>}
|
||||
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 && (
|
||||
<MarkdownContent
|
||||
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' && (
|
||||
@@ -457,7 +528,7 @@ function ToolLogsView({
|
||||
return (
|
||||
<ToolCallExpandable
|
||||
label={
|
||||
<span className="pl-[19px] py-1">
|
||||
<span className="pl-4 py-1 font-medium flex items-center">
|
||||
<span>Logs</span>
|
||||
{working && (
|
||||
<div className="mx-2 inline-block">
|
||||
@@ -475,7 +546,7 @@ function ToolLogsView({
|
||||
>
|
||||
<div
|
||||
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) => (
|
||||
<span key={i} className="font-mono text-sm text-textSubtle">
|
||||
@@ -493,16 +564,16 @@ const ProgressBar = ({ progress, total, message }: Omit<Progress, 'progressToken
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<div
|
||||
className="bg-blue-500 h-full transition-all duration-300"
|
||||
className="bg-primary h-full transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 animate-indeterminate bg-blue-500" />
|
||||
<div className="absolute inset-0 animate-indeterminate bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,14 +31,14 @@ export default function UserMessage({ message }: UserMessageProps) {
|
||||
const urls = extractUrls(displayText, []);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end mt-[16px] w-full opacity-0 animate-[appear_150ms_ease-in_forwards]">
|
||||
<div className="flex-col max-w-[85%]">
|
||||
<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%] w-fit">
|
||||
<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}>
|
||||
<MarkdownContent
|
||||
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>
|
||||
@@ -52,8 +52,8 @@ export default function UserMessage({ message }: UserMessageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative h-[22px] flex justify-end">
|
||||
<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="relative h-[22px] flex justify-end text-right">
|
||||
<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}
|
||||
</div>
|
||||
<div className="absolute right-0 pt-1">
|
||||
|
||||
600
ui/desktop/src/components/ViewRecipeModal.tsx
Normal file
600
ui/desktop/src/components/ViewRecipeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { cn } from '../../utils';
|
||||
import { Alert, AlertType } from '../alerts';
|
||||
import { AlertBox } from '../alerts';
|
||||
@@ -12,14 +11,73 @@ interface AlertPopoverProps {
|
||||
}
|
||||
|
||||
export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [hasShownInitial, setHasShownInitial] = React.useState(false);
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [wasAutoShown, setWasAutoShown] = React.useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasShownInitial, setHasShownInitial] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [wasAutoShown, setWasAutoShown] = useState(false);
|
||||
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
|
||||
const previousAlertsRef = useRef<Alert[]>([]);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const triggerRef = useRef<HTMLButtonElement>(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
|
||||
const startHideTimer = useCallback((duration = 3000) => {
|
||||
// Clear any existing timer
|
||||
@@ -98,78 +156,68 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
||||
: 'text-[#cc4b03]'; // Orange color for warning alerts
|
||||
|
||||
return (
|
||||
<div ref={popoverRef}>
|
||||
<Popover open={isOpen}>
|
||||
<div className="relative">
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="cursor-pointer flex items-center justify-center min-w-5 min-h-5 translate-y-[1px]"
|
||||
onMouseEnter={() => {
|
||||
setIsOpen(true);
|
||||
setIsHovered(true);
|
||||
setWasAutoShown(false);
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Start a short timer to allow moving to content
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (!isHovered) {
|
||||
setIsHovered(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
<div className={cn('relative', '-right-1', triggerColor)}>
|
||||
<FaCircle size={5} />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
|
||||
{/* Small connector area between trigger and content */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute -right-2 h-6 w-8 top-full"
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
<>
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="cursor-pointer flex items-center justify-center min-w-5 min-h-5 rounded hover:bg-background-muted"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setIsOpen(true);
|
||||
setIsHovered(true);
|
||||
setWasAutoShown(false);
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Start a short timer to allow moving to content
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
if (!isHovered) {
|
||||
setIsHovered(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PopoverContent
|
||||
className="w-[275px] p-0 rounded-lg overflow-hidden"
|
||||
align="end"
|
||||
alignOffset={-100}
|
||||
sideOffset={5}
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsHovered(false);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{alerts.map((alert, index) => (
|
||||
<div key={index} className={cn(index > 0 && 'border-t border-white/20')}>
|
||||
<AlertBox alert={alert} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
<div className={cn('relative', triggerColor)}>
|
||||
<FaCircle size={5} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Popover rendered separately to avoid blocking clicks */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed w-[275px] p-0 rounded-lg overflow-hidden bg-app border z-50 shadow-lg pointer-events-auto text-left"
|
||||
style={{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`,
|
||||
visibility: popoverPosition.top === 0 ? 'hidden' : 'visible',
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsHovered(false);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{alerts.map((alert, index) => (
|
||||
<div key={index} className={cn(index > 0 && 'border-t border-white/20')}>
|
||||
<AlertBox alert={alert} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { useConfig } from '../ConfigContext';
|
||||
import { View, ViewOptions } from '../../App';
|
||||
import { Orbit } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
|
||||
interface BottomMenuModeSelectionProps {
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
}
|
||||
|
||||
export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProps) => {
|
||||
const [isGooseModeMenuOpen, setIsGooseModeMenuOpen] = useState(false);
|
||||
export const BottomMenuModeSelection = () => {
|
||||
const [gooseMode, setGooseMode] = useState('auto');
|
||||
const gooseModeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { read, upsert } = useConfig();
|
||||
|
||||
const fetchCurrentMode = useCallback(async () => {
|
||||
@@ -29,41 +28,6 @@ export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProp
|
||||
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) => {
|
||||
if (gooseMode === newMode) {
|
||||
return;
|
||||
@@ -83,35 +47,34 @@ export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProp
|
||||
return mode ? mode.label : 'auto';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center" ref={gooseModeDropdownRef}>
|
||||
<button
|
||||
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>
|
||||
function getModeDescription(key: string) {
|
||||
const mode = all_goose_modes.find((mode) => mode.key === key);
|
||||
return mode ? mode.description : 'Automatic mode selection';
|
||||
}
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isGooseModeMenuOpen && (
|
||||
<div className="absolute bottom-[24px] right-0 w-[240px] py-2 bg-bgApp rounded-lg border border-borderSubtle">
|
||||
<div>
|
||||
{all_goose_modes.map((mode) => (
|
||||
return (
|
||||
<div title={`Current mode: ${getValueByKey(gooseMode)} - ${getModeDescription(gooseMode)}`}>
|
||||
<DropdownMenu>
|
||||
<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) => (
|
||||
<DropdownMenuItem key={mode.key} asChild>
|
||||
<ModeSelectionItem
|
||||
key={mode.key}
|
||||
mode={mode}
|
||||
currentMode={gooseMode}
|
||||
showDescription={false}
|
||||
isApproveModeConfigure={false}
|
||||
parentView="chat"
|
||||
setView={setView}
|
||||
handleModeChange={handleModeChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useModelAndProvider } from '../ModelAndProviderContext';
|
||||
import { useConfig } from '../ConfigContext';
|
||||
import { CoinIcon } from '../icons';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||
import {
|
||||
getCostForModel,
|
||||
initializeCostDatabase,
|
||||
@@ -170,8 +172,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
||||
};
|
||||
|
||||
const formatCost = (cost: number): string => {
|
||||
// Always show 6 decimal places for consistency
|
||||
return cost.toFixed(6);
|
||||
// Always show 4 decimal places for consistency
|
||||
return cost.toFixed(4);
|
||||
};
|
||||
|
||||
// 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 (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px]">
|
||||
<span className="text-xs font-mono">...</span>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px]">
|
||||
<span className="text-xs font-mono">...</span>
|
||||
</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'];
|
||||
if (freeProviders.includes(currentProvider.toLowerCase())) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center h-full text-textSubtle hover:text-textStandard transition-colors cursor-default translate-y-[1px]"
|
||||
title={`Local model (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`}
|
||||
>
|
||||
<span className="text-xs font-mono">$0.000000</span>
|
||||
</div>
|
||||
<>
|
||||
<Tooltip>
|
||||
<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]">
|
||||
<CoinIcon className="mr-1" size={16} />
|
||||
<span className="text-xs font-mono">0.0000</span>
|
||||
</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 (
|
||||
<div
|
||||
className={`flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] ${
|
||||
(pricingFailed || modelNotFound) && hasAttemptedFetch && initialLoadComplete
|
||||
? 'text-red-500 hover:text-red-400'
|
||||
: 'text-textSubtle hover:text-textStandard'
|
||||
}`}
|
||||
title={getUnavailableTooltip()}
|
||||
>
|
||||
<span className="text-xs font-mono">$0.000000</span>
|
||||
</div>
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
|
||||
<CoinIcon className="mr-1" size={16} />
|
||||
<span className="text-xs font-mono">0.0000</span>
|
||||
</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 (
|
||||
<div
|
||||
className={`flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] ${
|
||||
(pricingFailed || modelNotFound) && hasAttemptedFetch && initialLoadComplete
|
||||
? 'text-red-500 hover:text-red-400'
|
||||
: 'text-textSubtle hover:text-textStandard'
|
||||
}`}
|
||||
title={getTooltipContent()}
|
||||
>
|
||||
<span className="text-xs font-mono">
|
||||
{costInfo.currency || '$'}
|
||||
{formatCost(totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
|
||||
<CoinIcon className="mr-1" size={16} />
|
||||
<span className="text-xs font-mono">{formatCost(totalCost)}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getTooltipContent()}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="w-px h-4 bg-border-default mx-2" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
44
ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
Normal file
44
ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
ui/desktop/src/components/cli/CLIChatView.tsx
Normal file
17
ui/desktop/src/components/cli/CLIChatView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
128
ui/desktop/src/components/cli/CLIHub.tsx
Normal file
128
ui/desktop/src/components/cli/CLIHub.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
276
ui/desktop/src/components/common/ActivityHeatmap.tsx
Normal file
276
ui/desktop/src/components/common/ActivityHeatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
ui/desktop/src/components/common/Greeting.tsx
Normal file
56
ui/desktop/src/components/common/Greeting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Message } from '../../types/message';
|
||||
import { useChatContextManager } from './ChatContextManager';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface ContextHandlerProps {
|
||||
messages: Message[];
|
||||
@@ -8,6 +9,7 @@ interface ContextHandlerProps {
|
||||
chatId: string;
|
||||
workingDir: string;
|
||||
contextType: 'contextLengthExceeded' | 'summarizationRequested';
|
||||
onSummaryComplete?: () => void; // Add callback for when summary is complete
|
||||
}
|
||||
|
||||
export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
||||
@@ -16,6 +18,7 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
||||
chatId,
|
||||
workingDir,
|
||||
contextType,
|
||||
onSummaryComplete,
|
||||
}) => {
|
||||
const {
|
||||
summaryContent,
|
||||
@@ -39,6 +42,33 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
||||
|
||||
// Use a ref to track if we've started the fetch
|
||||
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
|
||||
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.`
|
||||
: `Summarization failed. Continue chatting or start a new session.`}
|
||||
</span>
|
||||
<button
|
||||
onClick={openNewSession}
|
||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
||||
>
|
||||
<Button onClick={openNewSession} className="text-xs transition-colors mt-1 flex items-center">
|
||||
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`
|
||||
: `Summarization requested`}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
||||
>
|
||||
<Button onClick={handleRetry} className="text-xs transition-colors mt-1 flex items-center">
|
||||
Retry loading summary
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -160,15 +184,15 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
||||
: `This summary includes key points from your conversation.`}
|
||||
</span>
|
||||
{shouldAllowSummaryInteraction && (
|
||||
<button
|
||||
<Button
|
||||
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{' '}
|
||||
{isContextLengthExceeded
|
||||
? '(you may continue your conversation based on the summary)'
|
||||
: ''}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import React, { useState } from '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 { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||
import { useChatContextManager } from './ChatContextManager';
|
||||
import { Message } from '../../types/message';
|
||||
|
||||
@@ -34,64 +43,66 @@ export const ManualSummarizeButton: React.FC<ManualSummarizeButtonProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Footer content for the confirmation modal
|
||||
const footerContent = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
const handleClose = () => {
|
||||
setIsConfirmationOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border-default mx-2" />
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
className={`flex items-center justify-center text-textSubtle hover:text-textStandard h-6 [&_svg]:size-4 ${
|
||||
isLoadingSummary || isLoading ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
disabled={isLoadingSummary || isLoading}
|
||||
title="Summarize conversation context"
|
||||
>
|
||||
<ScrollText size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
disabled={isLoadingSummary || isLoading}
|
||||
>
|
||||
<ScrollText size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isLoadingSummary ? 'Summarizing conversation...' : 'Summarize conversation context'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{isConfirmationOpen && (
|
||||
<Modal footer={footerContent} onClose={() => setIsConfirmationOpen(false)}>
|
||||
<div className="flex flex-col mb-6">
|
||||
<div>
|
||||
<Dialog open={isConfirmationOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ScrollText className="text-iconStandard" size={24} />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h2 className="text-2xl font-regular text-textStandard">Summarize Conversation</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-textStandard mb-4">
|
||||
Summarize Conversation
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will summarize your conversation history to save context space.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-textStandard">
|
||||
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
|
||||
the context limit.
|
||||
</p>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Geese } from '../icons/Geese';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
interface SessionSummaryModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -9,40 +17,6 @@ interface SessionSummaryModalProps {
|
||||
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({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -65,68 +39,49 @@ export function SessionSummaryModal({
|
||||
onSave(currentText);
|
||||
};
|
||||
|
||||
// Header Component - Icon, Title, and Description
|
||||
const Header = () => (
|
||||
<div className="flex flex-col items-center text-center mb-6">
|
||||
{/* Icon */}
|
||||
<div className="mb-4">
|
||||
<Geese width="48" height="50" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-medium text-gray-900 dark:text-white mb-2">Session Summary</h2>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-0 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.
|
||||
</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
|
||||
ref={textareaRef}
|
||||
defaultValue={summaryContent}
|
||||
className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 text-sm w-full min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
style={{
|
||||
textRendering: 'optimizeLegibility',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale',
|
||||
transform: 'translateZ(0)', // Force hardware acceleration
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Footer Buttons
|
||||
const modalActions = (
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<WiderBaseModal isOpen={isOpen} title="" actions={modalActions}>
|
||||
<div className="flex flex-col w-full">
|
||||
<Header />
|
||||
<SummaryContent />
|
||||
</div>
|
||||
</WiderBaseModal>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex flex-col items-center text-center">
|
||||
<div className="mb-4">
|
||||
<Geese width="48" height="50" />
|
||||
</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>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="w-full">
|
||||
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-3">
|
||||
Summarization
|
||||
</h3>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
defaultValue={summaryContent}
|
||||
className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 text-sm w-full min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
style={{
|
||||
textRendering: 'optimizeLegibility',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale',
|
||||
transform: 'translateZ(0)', // Force hardware acceleration
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save and Continue</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef, KeyboardEvent } from 'react';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, Close } from '../icons';
|
||||
import { debounce } from 'lodash';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
/**
|
||||
* Props for the SearchBar component
|
||||
@@ -142,13 +143,13 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
||||
|
||||
return (
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-full max-w-5xl mx-auto">
|
||||
<div className="relative flex flex-1 items-center h-full">
|
||||
<SearchIcon className="h-4 w-4 text-textSubtleInverse absolute left-3" />
|
||||
<div className="flex w-full items-center">
|
||||
<div className="relative flex flex-1 items-center h-full min-w-0">
|
||||
<SearchIcon className="h-4 w-4 text-text-inverse/70 absolute left-3" />
|
||||
<div className="w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -158,15 +159,15 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onChange={handleSearch}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search conversation..."
|
||||
className="w-full text-sm pl-9 pr-24 py-3 bg-bgAppInverse
|
||||
placeholder:text-textSubtleInverse focus:outline-none
|
||||
active:border-borderProminent"
|
||||
className="w-full text-sm pl-9 pr-24 py-3 bg-background-inverse text-text-inverse
|
||||
placeholder:text-text-inverse/50 focus:outline-none
|
||||
active:border-border-strong"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 flex h-full items-center justify-end">
|
||||
<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
|
||||
? `${localSearchResults.currentIndex}/${localSearchResults.count}`
|
||||
@@ -177,47 +178,51 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center h-auto px-4 gap-2">
|
||||
<button
|
||||
<div className="flex items-center justify-center h-auto px-4 gap-2 flex-shrink-0">
|
||||
<Button
|
||||
onClick={toggleCaseSensitive}
|
||||
variant="ghost"
|
||||
className={`flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 ${
|
||||
caseSensitive
|
||||
? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)]'
|
||||
: 'text-textSubtleInverse hover:text-textStandardInverse hover:bg-white/5'
|
||||
? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)] text-text-inverse hover:bg-white/25'
|
||||
: 'text-text-inverse/70 hover:text-text-inverse hover:bg-white/10'
|
||||
}`}
|
||||
title="Case Sensitive"
|
||||
>
|
||||
<span className="text-md font-normal">Aa</span>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<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
|
||||
className={`h-5 w-5 transition-opacity ${
|
||||
!hasResults
|
||||
? 'opacity-30'
|
||||
: 'text-textSubtleInverse hover:text-textStandardInverse'
|
||||
}`}
|
||||
className={`h-5 w-5 transition-opacity ${!hasResults ? 'opacity-30' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
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)"
|
||||
>
|
||||
<ArrowDown
|
||||
className={`h-5 w-5 transition-opacity ${
|
||||
!hasResults
|
||||
? 'opacity-30'
|
||||
: 'text-textSubtleInverse hover:text-textStandardInverse'
|
||||
}`}
|
||||
className={`h-5 w-5 transition-opacity ${!hasResults ? 'opacity-30' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button onClick={handleClose} className="p-1" title="Close (Esc)">
|
||||
<Close className="h-5 w-5 text-textSubtleInverse hover:text-textStandardInverse" />
|
||||
</button>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
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>
|
||||
|
||||
@@ -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 { SearchHighlighter } from '../../utils/searchHighlighter';
|
||||
import { debounce } from 'lodash';
|
||||
@@ -189,32 +189,76 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
[internalSearchResults, onNavigate]
|
||||
);
|
||||
|
||||
// Create stable refs for the handlers to avoid memory leaks
|
||||
const handlersRef = useRef({
|
||||
handleFindCommand: () => {
|
||||
if (isSearchVisible && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
searchInputRef.current.select();
|
||||
} else {
|
||||
setIsSearchVisible(true);
|
||||
}
|
||||
},
|
||||
handleFindNext: () => {
|
||||
if (isSearchVisible) {
|
||||
handleNavigate('next');
|
||||
}
|
||||
},
|
||||
handleFindPrevious: () => {
|
||||
if (isSearchVisible) {
|
||||
handleNavigate('prev');
|
||||
}
|
||||
},
|
||||
handleUseSelectionFind: () => {
|
||||
const selection = window.getSelection()?.toString().trim();
|
||||
if (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(() => {
|
||||
if (isSearchVisible && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
searchInputRef.current.select();
|
||||
} else {
|
||||
setIsSearchVisible(true);
|
||||
}
|
||||
}, [isSearchVisible]);
|
||||
handlersRef.current.handleFindCommand();
|
||||
}, []);
|
||||
|
||||
const handleFindNext = useCallback(() => {
|
||||
if (isSearchVisible) {
|
||||
handleNavigate('next');
|
||||
}
|
||||
}, [isSearchVisible, handleNavigate]);
|
||||
handlersRef.current.handleFindNext();
|
||||
}, []);
|
||||
|
||||
const handleFindPrevious = useCallback(() => {
|
||||
if (isSearchVisible) {
|
||||
handleNavigate('prev');
|
||||
}
|
||||
}, [isSearchVisible, handleNavigate]);
|
||||
handlersRef.current.handleFindPrevious();
|
||||
}, []);
|
||||
|
||||
const handleUseSelectionFind = useCallback(() => {
|
||||
const selection = window.getSelection()?.toString().trim();
|
||||
if (selection) {
|
||||
setInitialSearchTerm(selection);
|
||||
}
|
||||
handlersRef.current.handleUseSelectionFind();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -308,7 +352,8 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
||||
window.electron.off('find-previous', handleFindPrevious);
|
||||
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 (
|
||||
<div
|
||||
|
||||
116
ui/desktop/src/components/extensions/ExtensionsView.tsx
Normal file
116
ui/desktop/src/components/extensions/ExtensionsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
ui/desktop/src/components/hub.tsx
Normal file
118
ui/desktop/src/components/hub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
ui/desktop/src/components/icons/CoinIcon.tsx
Normal file
64
ui/desktop/src/components/icons/CoinIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
ui/desktop/src/components/icons/Discord.tsx
Normal file
18
ui/desktop/src/components/icons/Discord.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
ui/desktop/src/components/icons/LinkedIn.tsx
Normal file
18
ui/desktop/src/components/icons/LinkedIn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
ui/desktop/src/components/icons/Youtube.tsx
Normal file
18
ui/desktop/src/components/icons/Youtube.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,13 @@ import ChevronDown from './ChevronDown';
|
||||
import ChevronUp from './ChevronUp';
|
||||
import { ChevronRight } from './ChevronRight';
|
||||
import Close from './Close';
|
||||
import CoinIcon from './CoinIcon';
|
||||
import Copy from './Copy';
|
||||
import Discord from './Discord';
|
||||
import Document from './Document';
|
||||
import Edit from './Edit';
|
||||
import Idea from './Idea';
|
||||
import LinkedIn from './LinkedIn';
|
||||
import More from './More';
|
||||
import Refresh from './Refresh';
|
||||
import SensitiveHidden from './SensitiveHidden';
|
||||
@@ -20,6 +23,7 @@ import Send from './Send';
|
||||
import Settings from './Settings';
|
||||
import Time from './Time';
|
||||
import { Gear } from './Gear';
|
||||
import Youtube from './Youtube';
|
||||
import { Microphone } from './Microphone';
|
||||
|
||||
export {
|
||||
@@ -33,12 +37,15 @@ export {
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Close,
|
||||
CoinIcon,
|
||||
Copy,
|
||||
Discord,
|
||||
Document,
|
||||
Edit,
|
||||
Idea,
|
||||
Gear,
|
||||
Microphone,
|
||||
LinkedIn,
|
||||
More,
|
||||
Refresh,
|
||||
SensitiveHidden,
|
||||
@@ -46,4 +53,5 @@ export {
|
||||
Send,
|
||||
Settings,
|
||||
Time,
|
||||
Youtube,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
217
ui/desktop/src/components/pair.tsx
Normal file
217
ui/desktop/src/components/pair.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
|
||||
return (
|
||||
<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">
|
||||
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>
|
||||
|
||||
<div className="mb-4">
|
||||
@@ -23,7 +24,7 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
|
||||
type="text"
|
||||
value={description || ''}
|
||||
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"`}
|
||||
/>
|
||||
<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>
|
||||
<label className="block text-md text-textStandard mb-2 font-semibold">Requirement</label>
|
||||
<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}
|
||||
onChange={(e) =>
|
||||
onChange(key, { requirement: e.target.value as Parameter['requirement'] })
|
||||
@@ -55,7 +56,7 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
|
||||
type="text"
|
||||
value={defaultValue}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
146
ui/desktop/src/components/projects/AddSessionToProjectModal.tsx
Normal file
146
ui/desktop/src/components/projects/AddSessionToProjectModal.tsx
Normal 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;
|
||||
151
ui/desktop/src/components/projects/CreateProjectModal.tsx
Normal file
151
ui/desktop/src/components/projects/CreateProjectModal.tsx
Normal 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;
|
||||
47
ui/desktop/src/components/projects/ProjectCard.tsx
Normal file
47
ui/desktop/src/components/projects/ProjectCard.tsx
Normal 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;
|
||||
498
ui/desktop/src/components/projects/ProjectDetailsView.tsx
Normal file
498
ui/desktop/src/components/projects/ProjectDetailsView.tsx
Normal 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;
|
||||
33
ui/desktop/src/components/projects/ProjectsContainer.tsx
Normal file
33
ui/desktop/src/components/projects/ProjectsContainer.tsx
Normal 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;
|
||||
214
ui/desktop/src/components/projects/ProjectsView.tsx
Normal file
214
ui/desktop/src/components/projects/ProjectsView.tsx
Normal 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;
|
||||
181
ui/desktop/src/components/projects/UpdateProjectModal.tsx
Normal file
181
ui/desktop/src/components/projects/UpdateProjectModal.tsx
Normal 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;
|
||||
@@ -96,12 +96,12 @@ const daysOfWeekOptions: { value: string; label: string }[] = [
|
||||
{ value: '0', label: 'Sun' },
|
||||
];
|
||||
|
||||
const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
||||
const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1';
|
||||
const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1';
|
||||
const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300';
|
||||
const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1';
|
||||
const cronPreviewTextColor = 'text-xs text-text-subtle mt-1';
|
||||
const cronPreviewSpecialNoteColor = 'text-xs text-text-warning mt-1';
|
||||
const checkboxLabelClassName = 'flex items-center text-sm text-text-default';
|
||||
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 ExecutionMode = 'background' | 'foreground';
|
||||
@@ -543,15 +543,13 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm 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">
|
||||
<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-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="flex flex-col items-center">
|
||||
<img src={ClockIcon} alt="Clock" className="w-11 h-11 mb-2" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Create New Schedule
|
||||
</h2>
|
||||
<p className="text-base text-gray-500 dark:text-gray-400 mt-2 max-w-sm">
|
||||
<h2 className="text-base font-semibold text-text-prominent">Create New Schedule</h2>
|
||||
<p className="text-base text-text-subtle mt-2 max-w-sm">
|
||||
Create a new schedule using the settings below to do things like automatically run
|
||||
tasks or create files
|
||||
</p>
|
||||
@@ -564,12 +562,12 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
className="px-8 py-4 space-y-4 flex-grow overflow-y-auto"
|
||||
>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -348,8 +348,8 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm 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">
|
||||
<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-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">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Edit Schedule: {schedule?.id || ''}
|
||||
@@ -538,9 +538,9 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-schedule-form"
|
||||
variant="default"
|
||||
variant="ghost"
|
||||
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'}
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { Card } from '../ui/card';
|
||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
import { fetchSessionDetails, SessionDetails } from '../../sessions';
|
||||
import {
|
||||
getScheduleSessions,
|
||||
@@ -67,12 +66,10 @@ const ScheduleInfoCard = React.memo<{
|
||||
}, [scheduleDetails.process_start_time]);
|
||||
|
||||
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="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{scheduleDetails.id}
|
||||
</h3>
|
||||
<h3 className="text-base font-semibold text-text-prominent">{scheduleDetails.id}</h3>
|
||||
<div className="mt-2 md:mt-0 flex items-center gap-2">
|
||||
{scheduleDetails.currently_running && (
|
||||
<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>
|
||||
<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}
|
||||
</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}
|
||||
</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}
|
||||
</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}
|
||||
</p>
|
||||
{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={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
@@ -115,13 +112,13 @@ const ScheduleInfoCard = React.memo<{
|
||||
</p>
|
||||
)}
|
||||
{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>{' '}
|
||||
{scheduleDetails.current_session_id}
|
||||
</p>
|
||||
)}
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -486,13 +483,10 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
|
||||
if (!scheduleId) {
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col items-center justify-center bg-app text-textStandard p-8">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
<div className="h-screen w-full flex flex-col items-center justify-center bg-white dark:bg-gray-900 text-text-default p-8">
|
||||
<BackButton onClick={onNavigateBack} />
|
||||
<h1 className="text-2xl font-medium text-gray-900 dark:text-white mt-4">
|
||||
Schedule Not Found
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
<h1 className="text-2xl font-medium text-text-prominent mt-4">Schedule Not Found</h1>
|
||||
<p className="text-text-subtle mt-2">
|
||||
No schedule ID was provided. Please return to the schedules list and select a schedule.
|
||||
</p>
|
||||
</div>
|
||||
@@ -500,31 +494,24 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
|
||||
<div className="h-screen w-full flex flex-col bg-background-default text-text-default">
|
||||
<div className="px-8 pt-6 pb-4 border-b border-border-subtle flex-shrink-0">
|
||||
<BackButton onClick={onNavigateBack} />
|
||||
<h1 className="text-3xl font-medium text-gray-900 dark:text-white mt-1">
|
||||
Schedule Details
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Viewing Schedule ID: {scheduleId}
|
||||
</p>
|
||||
<h1 className="text-3xl font-medium text-text-prominent mt-1">Schedule Details</h1>
|
||||
<p className="text-sm text-text-subtle mt-1">Viewing Schedule ID: {scheduleId}</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-grow">
|
||||
<div className="p-8 space-y-6">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Schedule Information
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-text-prominent mb-3">Schedule Information</h2>
|
||||
{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...
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -534,7 +521,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
</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">
|
||||
<Button
|
||||
onClick={handleRunNow}
|
||||
@@ -619,19 +606,17 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
</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
|
||||
</h2>
|
||||
{isLoadingSessions && (
|
||||
<p className="text-gray-500 dark:text-gray-400">Loading sessions...</p>
|
||||
)}
|
||||
{isLoadingSessions && <p className="text-text-subtle">Loading sessions...</p>}
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{!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.
|
||||
</p>
|
||||
)}
|
||||
@@ -641,7 +626,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
{sessions.map((session) => (
|
||||
<Card
|
||||
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)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -652,23 +637,23 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
{session.name || `Session ID: ${session.id}`}{' '}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p className="text-xs text-text-subtle mt-1">
|
||||
Created:{' '}
|
||||
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{session.workingDir && (
|
||||
<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}
|
||||
>
|
||||
Dir: {session.workingDir}
|
||||
@@ -676,11 +661,11 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
)}
|
||||
{session.accumulatedTotalTokens !== undefined &&
|
||||
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}
|
||||
</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>
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
@@ -69,8 +69,8 @@ export const ScheduleFromRecipeModal: React.FC<ScheduleFromRecipeModalProps> = (
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm 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">
|
||||
<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-background-default shadow-xl rounded-lg z-50 flex flex-col">
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Create Schedule from Recipe
|
||||
@@ -123,7 +123,7 @@ export const ScheduleFromRecipeModal: React.FC<ScheduleFromRecipeModalProps> = (
|
||||
<Button
|
||||
type="button"
|
||||
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
|
||||
</Button>
|
||||
|
||||
@@ -10,23 +10,21 @@ import {
|
||||
inspectRunningJob,
|
||||
ScheduledJob,
|
||||
} from '../../schedule';
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
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 { EditScheduleModal } from './EditScheduleModal';
|
||||
import ScheduleDetailView from './ScheduleDetailView';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import cronstrue from 'cronstrue';
|
||||
import { formatToLocalDateWithTimezone } from '../../utils/date';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
|
||||
interface SchedulesViewProps {
|
||||
onClose: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Memoized ScheduleCard component to prevent unnecessary re-renders
|
||||
@@ -75,146 +73,133 @@ const ScheduleCard = React.memo<{
|
||||
|
||||
return (
|
||||
<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)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-grow mr-2 overflow-hidden">
|
||||
<h3
|
||||
className="text-base font-semibold text-gray-900 dark:text-white truncate"
|
||||
title={job.id}
|
||||
>
|
||||
{job.id}
|
||||
</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 && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mode:{' '}
|
||||
<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]" title={job.id}>
|
||||
{job.id}
|
||||
</h3>
|
||||
{job.execution_mode && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
job.execution_mode === 'foreground'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
||||
? 'bg-background-accent text-text-on-accent'
|
||||
: 'bg-background-medium text-text-default'
|
||||
}`}
|
||||
>
|
||||
{job.execution_mode === 'foreground' ? '🖥️ Foreground' : '⚡ Background'}
|
||||
{job.execution_mode === 'foreground' ? '🖥️' : '⚡'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<p className="text-xs text-green-500 dark:text-green-400 mt-1 font-semibold flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
||||
Currently Running
|
||||
</p>
|
||||
)}
|
||||
{job.paused && (
|
||||
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1 font-semibold flex items-center">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</p>
|
||||
)}
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<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 rounded-full mr-1 animate-pulse"></span>
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
{job.paused && (
|
||||
<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" />
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-sm mb-2 line-clamp-2" title={readableCron}>
|
||||
{readableCron}
|
||||
</p>
|
||||
<div className="flex items-center text-xs text-text-muted">
|
||||
<span>Last run: {formattedLastRun}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!job.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(job);
|
||||
}}
|
||||
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"
|
||||
disabled={isPausing || isDeleting || isSubmitting}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</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 && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (job.paused) {
|
||||
onUnpause(job.id);
|
||||
} else {
|
||||
onPause(job.id);
|
||||
}
|
||||
}}
|
||||
disabled={isPausing || isDeleting}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
{job.paused ? (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(job);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<span>Edit</span>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (job.paused) {
|
||||
onUnpause(job.id);
|
||||
} else {
|
||||
onPause(job.id);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<span>{job.paused ? 'Resume schedule' : 'Stop schedule'}</span>
|
||||
{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
|
||||
</>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInspect(job.id);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<span>Inspect</span>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKill(job.id);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<span>Kill job</span>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<hr className="border-gray-200 dark:border-gray-600 my-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(job.id);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<span>Delete</span>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInspect(job.id);
|
||||
}}
|
||||
disabled={isInspecting || isKilling}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Inspect
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKill(job.id);
|
||||
}}
|
||||
disabled={isKilling || isInspecting}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
Kill
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(job.id);
|
||||
}}
|
||||
disabled={isPausing || isDeleting || isKilling || isInspecting}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -224,7 +209,7 @@ const ScheduleCard = React.memo<{
|
||||
|
||||
ScheduleCard.displayName = 'ScheduleCard';
|
||||
|
||||
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -573,80 +558,90 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
|
||||
<BackButton onClick={onClose} />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mt-2">
|
||||
Schedules Management
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<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
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || isLoading}
|
||||
variant="outline"
|
||||
className="w-full md:w-auto flex items-center gap-2 justify-center rounded-full [&>svg]:!size-4"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
<>
|
||||
<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">Scheduler</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</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>
|
||||
|
||||
{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">
|
||||
Error: {apiError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 relative px-8">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="h-full relative">
|
||||
{apiError && (
|
||||
<div className="mb-4 p-4 bg-background-error border border-border-error rounded-md">
|
||||
<p className="text-text-error text-sm">Error: {apiError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Existing Schedules
|
||||
</h2>
|
||||
{isLoading && schedules.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400">Loading schedules...</p>
|
||||
)}
|
||||
{!isLoading && !apiError && schedules.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No schedules found. Create one to get started!
|
||||
</p>
|
||||
)}
|
||||
{isLoading && schedules.length === 0 && (
|
||||
<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 && schedules.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{schedules.map((job) => (
|
||||
<ScheduleCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onNavigateToDetail={handleNavigateToScheduleDetail}
|
||||
onEdit={handleOpenEditModal}
|
||||
onPause={handlePauseSchedule}
|
||||
onUnpause={handleUnpauseSchedule}
|
||||
onKill={handleKillRunningJob}
|
||||
onInspect={handleInspectRunningJob}
|
||||
onDelete={handleDeleteSchedule}
|
||||
isPausing={pausingScheduleIds.has(job.id)}
|
||||
isDeleting={deletingScheduleIds.has(job.id)}
|
||||
isKilling={killingScheduleIds.has(job.id)}
|
||||
isInspecting={inspectingScheduleIds.has(job.id)}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && !apiError && schedules.length === 0 && (
|
||||
<div className="flex flex-col pt-4 pb-12">
|
||||
<CircleDotDashed className="h-5 w-5 text-text-muted mb-3.5" />
|
||||
<p className="text-base text-text-muted font-light mb-2">No schedules yet</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && schedules.length > 0 && (
|
||||
<div className="space-y-2 pb-8">
|
||||
{schedules.map((job) => (
|
||||
<ScheduleCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onNavigateToDetail={handleNavigateToScheduleDetail}
|
||||
onEdit={handleOpenEditModal}
|
||||
onPause={handlePauseSchedule}
|
||||
onUnpause={handleUnpauseSchedule}
|
||||
onKill={handleKillRunningJob}
|
||||
onInspect={handleInspectRunningJob}
|
||||
onDelete={handleDeleteSchedule}
|
||||
isPausing={pausingScheduleIds.has(job.id)}
|
||||
isDeleting={deletingScheduleIds.has(job.id)}
|
||||
isKilling={killingScheduleIds.has(job.id)}
|
||||
isInspecting={inspectingScheduleIds.has(job.id)}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</MainPanelLayout>
|
||||
|
||||
<CreateScheduleModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={handleCloseCreateModal}
|
||||
@@ -662,7 +657,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
isLoadingExternally={isSubmitting}
|
||||
apiErrorExternally={submitApiError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,15 +9,65 @@ import {
|
||||
Check,
|
||||
Target,
|
||||
LoaderCircle,
|
||||
AlertCircle,
|
||||
ChevronLeft,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
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 { 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 {
|
||||
session: SessionDetails;
|
||||
@@ -29,6 +79,94 @@ interface SessionHistoryViewProps {
|
||||
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> = ({
|
||||
session,
|
||||
isLoading,
|
||||
@@ -106,102 +244,140 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleLaunchInNewWindow = () => {
|
||||
if (session) {
|
||||
console.log('Launching session in new window:', session.session_id);
|
||||
console.log('Session details:', session);
|
||||
|
||||
// Get the working directory from the session metadata
|
||||
const workingDir = session.metadata?.working_dir;
|
||||
|
||||
if (workingDir) {
|
||||
console.log(
|
||||
`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 (
|
||||
<div className="h-screen w-full flex flex-col">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
|
||||
<SessionHeaderCard onBack={onBack}>
|
||||
<div className="ml-8">
|
||||
<h1 className="text-lg text-textStandardInverse">
|
||||
{session.metadata.description || session.session_id}
|
||||
</h1>
|
||||
<div className="flex items-center text-sm text-textSubtle mt-1 space-x-5">
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{formatMessageTimestamp(session.messages[0]?.created)}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<MessageSquareText className="w-4 h-4 mr-1" />
|
||||
{session.metadata.message_count}
|
||||
</span>
|
||||
{session.metadata.total_tokens !== null && (
|
||||
<span className="flex items-center">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{session.metadata.total_tokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-textSubtle space-x-5">
|
||||
<span className="flex items-center">
|
||||
<Folder className="w-4 h-4 mr-1" />
|
||||
{session.metadata.working_dir}
|
||||
</span>
|
||||
</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 ? (
|
||||
<>
|
||||
<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 ? (
|
||||
<>
|
||||
<LoaderCircle className="w-7 h-7 animate-spin mr-2" />
|
||||
<span>Sharing...</span>
|
||||
<div className="flex items-center text-text-muted text-sm space-x-5 font-mono">
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{formatMessageTimestamp(session.messages[0]?.created)}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<MessageSquareText className="w-4 h-4 mr-1" />
|
||||
{session.metadata.message_count}
|
||||
</span>
|
||||
{session.metadata.total_tokens !== null && (
|
||||
<span className="flex items-center">
|
||||
<Target className="w-4 h-4 mr-1" />
|
||||
{session.metadata.total_tokens.toLocaleString()}
|
||||
</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" />
|
||||
{session.metadata.working_dir}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share2 className="w-7 h-7" />
|
||||
</>
|
||||
<div className="flex items-center text-text-muted text-sm">
|
||||
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
|
||||
<span>Loading session details...</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</SessionHeader>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</SessionHeaderCard>
|
||||
<SessionMessages
|
||||
messages={session.messages}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
<SessionMessages
|
||||
messages={session.messages}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
|
||||
<Modal open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
|
||||
<ModalContent className="sm:max-w-md p-0 bg-bgApp dark:bg-bgApp dark:border-borderSubtle">
|
||||
<div className="flex justify-center mt-4">
|
||||
<Share2 className="w-6 h-6 text-textStandard" />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 px-6 text-center">
|
||||
<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">
|
||||
<Dialog open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex justify-center items-center gap-2">
|
||||
<Share2 className="w-6 h-6 text-textStandard" />
|
||||
Share Session (beta)
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
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">
|
||||
<code className="text-sm text-textStandard dark:text-textStandardInverse overflow-x-hidden break-all pr-8 w-full">
|
||||
{shareLink}
|
||||
</code>
|
||||
<Button
|
||||
size="icon"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onClick={handleCopyLink}
|
||||
@@ -213,19 +389,14 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsShareModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
26
ui/desktop/src/components/sessions/SessionItem.tsx
Normal file
26
ui/desktop/src/components/sessions/SessionItem.tsx
Normal 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
Reference in New Issue
Block a user