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
|
- name: Checkout code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
# Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior
|
||||||
|
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# Update versions before build
|
# Update versions before build
|
||||||
|
|||||||
3
.github/workflows/bundle-desktop-linux.yml
vendored
3
.github/workflows/bundle-desktop-linux.yml
vendored
@@ -28,7 +28,8 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
# Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior
|
||||||
|
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# 2) Update versions before build
|
# 2) Update versions before build
|
||||||
|
|||||||
3
.github/workflows/bundle-desktop-windows.yml
vendored
3
.github/workflows/bundle-desktop-windows.yml
vendored
@@ -45,7 +45,8 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
# Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior
|
||||||
|
ref: ${{ inputs.ref != '' && inputs.ref || '' }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# 2) Configure AWS credentials for code signing
|
# 2) Configure AWS credentials for code signing
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod config_management;
|
|||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod extension;
|
pub mod extension;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod project;
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
pub mod reply;
|
pub mod reply;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
@@ -27,4 +28,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
|
|||||||
.merge(recipe::routes(state.clone()))
|
.merge(recipe::routes(state.clone()))
|
||||||
.merge(session::routes(state.clone()))
|
.merge(session::routes(state.clone()))
|
||||||
.merge(schedule::routes(state.clone()))
|
.merge(schedule::routes(state.clone()))
|
||||||
|
.merge(project::routes(state.clone()))
|
||||||
}
|
}
|
||||||
|
|||||||
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 super::utils::verify_secret_key;
|
||||||
|
use chrono::{DateTime, Datelike};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -13,6 +15,7 @@ use goose::session;
|
|||||||
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
|
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
|
||||||
use goose::session::SessionMetadata;
|
use goose::session::SessionMetadata;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use tracing::{error, info};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -33,6 +36,29 @@ pub struct SessionHistoryResponse {
|
|||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionInsights {
|
||||||
|
/// Total number of sessions
|
||||||
|
total_sessions: usize,
|
||||||
|
/// Most active working directories with session counts
|
||||||
|
most_active_dirs: Vec<(String, usize)>,
|
||||||
|
/// Average session duration in minutes
|
||||||
|
avg_session_duration: f64,
|
||||||
|
/// Total tokens used across all sessions
|
||||||
|
total_tokens: i64,
|
||||||
|
/// Activity trend for the last 7 days
|
||||||
|
recent_activity: Vec<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ActivityHeatmapCell {
|
||||||
|
pub week: usize,
|
||||||
|
pub day: usize,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/sessions",
|
path = "/sessions",
|
||||||
@@ -106,10 +132,174 @@ async fn get_session_history(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/sessions/insights",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Session insights retrieved successfully", body = SessionInsights),
|
||||||
|
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("api_key" = [])
|
||||||
|
),
|
||||||
|
tag = "Session Management"
|
||||||
|
)]
|
||||||
|
async fn get_session_insights(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<SessionInsights>, StatusCode> {
|
||||||
|
info!("Received request for session insights");
|
||||||
|
|
||||||
|
verify_secret_key(&headers, &state)?;
|
||||||
|
|
||||||
|
let sessions = get_valid_sorted_sessions(SortOrder::Descending).map_err(|e| {
|
||||||
|
error!("Failed to get session info: {:?}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Filter out sessions without descriptions
|
||||||
|
let sessions: Vec<SessionInfo> = sessions
|
||||||
|
.into_iter()
|
||||||
|
.filter(|session| !session.metadata.description.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
info!("Found {} sessions with descriptions", sessions.len());
|
||||||
|
|
||||||
|
// Calculate insights
|
||||||
|
let total_sessions = sessions.len();
|
||||||
|
|
||||||
|
// Debug: Log if we have very few sessions, which might indicate filtering issues
|
||||||
|
if total_sessions == 0 {
|
||||||
|
info!("Warning: No sessions found with descriptions");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track directory usage
|
||||||
|
let mut dir_counts: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut total_duration = 0.0;
|
||||||
|
let mut total_tokens = 0;
|
||||||
|
let mut activity_by_date: HashMap<String, usize> = HashMap::new();
|
||||||
|
|
||||||
|
for session in &sessions {
|
||||||
|
// Track directory usage
|
||||||
|
let dir = session.metadata.working_dir.to_string_lossy().to_string();
|
||||||
|
*dir_counts.entry(dir).or_insert(0) += 1;
|
||||||
|
|
||||||
|
// Track tokens - only add positive values to prevent negative totals
|
||||||
|
if let Some(tokens) = session.metadata.accumulated_total_tokens {
|
||||||
|
if tokens > 0 {
|
||||||
|
total_tokens += tokens as i64;
|
||||||
|
} else if tokens < 0 {
|
||||||
|
// Log negative token values for debugging
|
||||||
|
info!(
|
||||||
|
"Warning: Session {} has negative accumulated_total_tokens: {}",
|
||||||
|
session.id, tokens
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track activity by date
|
||||||
|
if let Ok(date) = DateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC") {
|
||||||
|
let date_str = date.format("%Y-%m-%d").to_string();
|
||||||
|
*activity_by_date.entry(date_str).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate session duration from messages
|
||||||
|
let session_path = session::get_path(session::Identifier::Name(session.id.clone()));
|
||||||
|
if let Ok(session_path) = session_path {
|
||||||
|
if let Ok(messages) = session::read_messages(&session_path) {
|
||||||
|
if let (Some(first), Some(last)) = (messages.first(), messages.last()) {
|
||||||
|
let duration = (last.created - first.created) as f64 / 60.0; // Convert to minutes
|
||||||
|
total_duration += duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get top 3 most active directories
|
||||||
|
let mut dir_vec: Vec<(String, usize)> = dir_counts.into_iter().collect();
|
||||||
|
dir_vec.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
let most_active_dirs = dir_vec.into_iter().take(3).collect();
|
||||||
|
|
||||||
|
// Calculate average session duration
|
||||||
|
let avg_session_duration = if total_sessions > 0 {
|
||||||
|
total_duration / total_sessions as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get last 7 days of activity
|
||||||
|
let mut activity_vec: Vec<(String, usize)> = activity_by_date.into_iter().collect();
|
||||||
|
activity_vec.sort_by(|a, b| b.0.cmp(&a.0)); // Sort by date descending
|
||||||
|
let recent_activity = activity_vec.into_iter().take(7).collect();
|
||||||
|
|
||||||
|
let insights = SessionInsights {
|
||||||
|
total_sessions,
|
||||||
|
most_active_dirs,
|
||||||
|
avg_session_duration,
|
||||||
|
total_tokens,
|
||||||
|
recent_activity,
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Returning insights: {:?}", insights);
|
||||||
|
Ok(Json(insights))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/sessions/activity-heatmap",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Activity heatmap data", body = [ActivityHeatmapCell]),
|
||||||
|
(status = 401, description = "Unauthorized - Invalid or missing API key"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
),
|
||||||
|
security(("api_key" = [])),
|
||||||
|
tag = "Session Management"
|
||||||
|
)]
|
||||||
|
async fn get_activity_heatmap(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Vec<ActivityHeatmapCell>>, StatusCode> {
|
||||||
|
verify_secret_key(&headers, &state)?;
|
||||||
|
|
||||||
|
let sessions = get_valid_sorted_sessions(SortOrder::Descending)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
// Only sessions with a description
|
||||||
|
let sessions: Vec<SessionInfo> = sessions
|
||||||
|
.into_iter()
|
||||||
|
.filter(|session| !session.metadata.description.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Map: (week, day) -> count
|
||||||
|
let mut heatmap: std::collections::HashMap<(usize, usize), usize> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for session in &sessions {
|
||||||
|
if let Ok(date) =
|
||||||
|
chrono::NaiveDateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
{
|
||||||
|
let date = date.date();
|
||||||
|
let week = date.iso_week().week() as usize - 1; // 0-based week
|
||||||
|
let day = date.weekday().num_days_from_sunday() as usize; // 0=Sun, 6=Sat
|
||||||
|
*heatmap.entry((week, day)).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for ((week, day), count) in heatmap {
|
||||||
|
result.push(ActivityHeatmapCell { week, day, count });
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
// Configure routes for this module
|
// Configure routes for this module
|
||||||
pub fn routes(state: Arc<AppState>) -> Router {
|
pub fn routes(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/sessions", get(list_sessions))
|
.route("/sessions", get(list_sessions))
|
||||||
.route("/sessions/{session_id}", get(get_session_history))
|
.route("/sessions/{session_id}", get(get_session_history))
|
||||||
|
.route("/sessions/insights", get(get_session_insights))
|
||||||
|
.route("/sessions/activity-heatmap", get(get_activity_heatmap))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod context_mgmt;
|
|||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
pub mod project;
|
||||||
pub mod prompt_template;
|
pub mod prompt_template;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
|
|||||||
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(),
|
working_dir: current_dir.clone(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
schedule_id: Some(job.id.clone()),
|
schedule_id: Some(job.id.clone()),
|
||||||
|
project_id: None,
|
||||||
message_count: all_session_messages.len(),
|
message_count: all_session_messages.len(),
|
||||||
total_tokens: None,
|
total_tokens: None,
|
||||||
input_tokens: None,
|
input_tokens: None,
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ pub struct SessionMetadata {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
/// ID of the schedule that triggered this session, if any
|
/// ID of the schedule that triggered this session, if any
|
||||||
pub schedule_id: Option<String>,
|
pub schedule_id: Option<String>,
|
||||||
|
/// ID of the project this session belongs to, if any
|
||||||
|
pub project_id: Option<String>,
|
||||||
/// Number of messages in the session
|
/// Number of messages in the session
|
||||||
pub message_count: usize,
|
pub message_count: usize,
|
||||||
/// The total number of tokens used in the session. Retrieved from the provider's last usage.
|
/// The total number of tokens used in the session. Retrieved from the provider's last usage.
|
||||||
@@ -68,6 +70,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
|
|||||||
description: String,
|
description: String,
|
||||||
message_count: usize,
|
message_count: usize,
|
||||||
schedule_id: Option<String>, // For backward compatibility
|
schedule_id: Option<String>, // For backward compatibility
|
||||||
|
project_id: Option<String>, // For backward compatibility
|
||||||
total_tokens: Option<i32>,
|
total_tokens: Option<i32>,
|
||||||
input_tokens: Option<i32>,
|
input_tokens: Option<i32>,
|
||||||
output_tokens: Option<i32>,
|
output_tokens: Option<i32>,
|
||||||
@@ -89,6 +92,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
|
|||||||
description: helper.description,
|
description: helper.description,
|
||||||
message_count: helper.message_count,
|
message_count: helper.message_count,
|
||||||
schedule_id: helper.schedule_id,
|
schedule_id: helper.schedule_id,
|
||||||
|
project_id: helper.project_id,
|
||||||
total_tokens: helper.total_tokens,
|
total_tokens: helper.total_tokens,
|
||||||
input_tokens: helper.input_tokens,
|
input_tokens: helper.input_tokens,
|
||||||
output_tokens: helper.output_tokens,
|
output_tokens: helper.output_tokens,
|
||||||
@@ -113,6 +117,7 @@ impl SessionMetadata {
|
|||||||
working_dir,
|
working_dir,
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
schedule_id: None,
|
schedule_id: None,
|
||||||
|
project_id: None,
|
||||||
message_count: 0,
|
message_count: 0,
|
||||||
total_tokens: None,
|
total_tokens: None,
|
||||||
input_tokens: None,
|
input_tokens: None,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub struct ConfigurableMockScheduler {
|
|||||||
sessions_data: Arc<Mutex<HashMap<String, Vec<(String, SessionMetadata)>>>>,
|
sessions_data: Arc<Mutex<HashMap<String, Vec<(String, SessionMetadata)>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl ConfigurableMockScheduler {
|
impl ConfigurableMockScheduler {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -404,6 +405,7 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) ->
|
|||||||
working_dir: PathBuf::from(working_dir),
|
working_dir: PathBuf::from(working_dir),
|
||||||
description: "Test session".to_string(),
|
description: "Test session".to_string(),
|
||||||
schedule_id: Some("test_job".to_string()),
|
schedule_id: Some("test_job".to_string()),
|
||||||
|
project_id: None,
|
||||||
total_tokens: Some(100),
|
total_tokens: Some(100),
|
||||||
input_tokens: Some(50),
|
input_tokens: Some(50),
|
||||||
output_tokens: Some(50),
|
output_tokens: Some(50),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The Goose Desktop App is an Electron application built with TypeScript, React, a
|
|||||||
2. Create a main component file (e.g., `YourFeatureView.tsx`)
|
2. Create a main component file (e.g., `YourFeatureView.tsx`)
|
||||||
3. Add your view type to the `View` type in `App.tsx`
|
3. Add your view type to the `View` type in `App.tsx`
|
||||||
4. Import and add your component to the render section in `App.tsx`
|
4. Import and add your component to the render section in `App.tsx`
|
||||||
5. Add navigation to your view from other components (e.g., adding a button in `BottomMenu.tsx` or `MoreMenu.tsx`)
|
5. Add navigation to your view from other components (e.g., adding a new route or button in `App.tsx`)
|
||||||
|
|
||||||
## State Management
|
## State Management
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ let cfg = {
|
|||||||
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
|
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
|
||||||
signingRole: process.env.WINDOW_SIGNING_ROLE,
|
signingRole: process.env.WINDOW_SIGNING_ROLE,
|
||||||
rfc3161TimeStampServer: 'http://timestamp.digicert.com',
|
rfc3161TimeStampServer: 'http://timestamp.digicert.com',
|
||||||
signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256'
|
signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256',
|
||||||
},
|
},
|
||||||
// Protocol registration
|
// Protocol registration
|
||||||
protocols: [
|
protocols: [
|
||||||
{
|
{
|
||||||
name: "GooseProtocol",
|
name: 'GooseProtocol',
|
||||||
schemes: ["goose"]
|
schemes: ['goose'],
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
// macOS Info.plist extensions for drag-and-drop support
|
// macOS Info.plist extensions for drag-and-drop support
|
||||||
extendInfo: {
|
extendInfo: {
|
||||||
@@ -44,9 +44,9 @@ let cfg = {
|
|||||||
osxNotarize: {
|
osxNotarize: {
|
||||||
appleId: process.env['APPLE_ID'],
|
appleId: process.env['APPLE_ID'],
|
||||||
appleIdPassword: process.env['APPLE_ID_PASSWORD'],
|
appleIdPassword: process.env['APPLE_ID_PASSWORD'],
|
||||||
teamId: process.env['APPLE_TEAM_ID']
|
teamId: process.env['APPLE_TEAM_ID'],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
if (process.env['APPLE_ID'] === undefined) {
|
if (process.env['APPLE_ID'] === undefined) {
|
||||||
delete cfg.osxNotarize;
|
delete cfg.osxNotarize;
|
||||||
@@ -62,12 +62,12 @@ module.exports = {
|
|||||||
config: {
|
config: {
|
||||||
repository: {
|
repository: {
|
||||||
owner: 'block',
|
owner: 'block',
|
||||||
name: 'goose'
|
name: 'goose',
|
||||||
},
|
},
|
||||||
prerelease: false,
|
prerelease: false,
|
||||||
draft: true
|
draft: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
makers: [
|
makers: [
|
||||||
{
|
{
|
||||||
@@ -76,22 +76,22 @@ module.exports = {
|
|||||||
config: {
|
config: {
|
||||||
arch: process.env.ELECTRON_ARCH === 'x64' ? ['x64'] : ['arm64'],
|
arch: process.env.ELECTRON_ARCH === 'x64' ? ['x64'] : ['arm64'],
|
||||||
options: {
|
options: {
|
||||||
icon: 'src/images/icon.ico'
|
icon: 'src/images/icon.ico',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '@electron-forge/maker-deb',
|
name: '@electron-forge/maker-deb',
|
||||||
config: {
|
config: {
|
||||||
name: 'Goose',
|
name: 'Goose',
|
||||||
bin: 'Goose'
|
bin: 'Goose',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '@electron-forge/maker-rpm',
|
name: '@electron-forge/maker-rpm',
|
||||||
config: {
|
config: {
|
||||||
name: 'Goose',
|
name: 'Goose',
|
||||||
bin: 'Goose'
|
bin: 'Goose',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -102,17 +102,17 @@ module.exports = {
|
|||||||
build: [
|
build: [
|
||||||
{
|
{
|
||||||
entry: 'src/main.ts',
|
entry: 'src/main.ts',
|
||||||
config: 'vite.main.config.ts',
|
config: 'vite.main.config.mts',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
entry: 'src/preload.ts',
|
entry: 'src/preload.ts',
|
||||||
config: 'vite.preload.config.ts',
|
config: 'vite.preload.config.mts',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
renderer: [
|
renderer: [
|
||||||
{
|
{
|
||||||
name: 'main_window',
|
name: 'main_window',
|
||||||
config: 'vite.renderer.config.ts',
|
config: 'vite.renderer.config.mts',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://127.0.0.1:* https://api.github.com https://github.com https://objects.githubusercontent.com; object-src 'none'; frame-src 'none'; font-src 'self' data: https:; media-src 'self' mediastream:; form-action 'none'; base-uri 'self'; manifest-src 'self'; worker-src 'self';" />
|
||||||
<title>Goose</title>
|
<title>Goose</title>
|
||||||
<script>
|
<script>
|
||||||
// Initialize theme before any content loads
|
// Initialize theme before any content loads
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"license": {
|
"license": {
|
||||||
"name": "Apache-2.0"
|
"name": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "1.0.34"
|
"version": "1.0.35"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/agent/tools": {
|
"/agent/tools": {
|
||||||
@@ -1564,6 +1564,10 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
},
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"$ref": "#/components/schemas/Role"
|
"$ref": "#/components/schemas/Role"
|
||||||
}
|
}
|
||||||
@@ -2225,6 +2229,11 @@
|
|||||||
"description": "The number of output tokens used in the session. Retrieved from the provider's last usage.",
|
"description": "The number of output tokens used in the session. Retrieved from the provider's last usage.",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"project_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ID of the project this session belongs to, if any",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"schedule_id": {
|
"schedule_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "ID of the schedule that triggered this session, if any",
|
"description": "ID of the schedule that triggered this session, if any",
|
||||||
|
|||||||
4655
ui/desktop/package-lock.json
generated
4655
ui/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "goose-app",
|
"name": "goose-app",
|
||||||
"productName": "Goose",
|
"productName": "Goose",
|
||||||
"version": "1.0.36",
|
"version": "1.1.0-alpha.4",
|
||||||
"description": "Goose App",
|
"description": "Goose App",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.9.0"
|
"node": "^22.9.0"
|
||||||
@@ -33,6 +33,55 @@
|
|||||||
"prepare": "cd ../.. && husky install",
|
"prepare": "cd ../.. && husky install",
|
||||||
"start-alpha-gui": "ALPHA=true npm run start-gui"
|
"start-alpha-gui": "ALPHA=true npm run start-gui"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "^0.0.72",
|
||||||
|
"@ai-sdk/ui-utils": "^1.0.2",
|
||||||
|
"@hey-api/client-fetch": "^0.8.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.7",
|
||||||
|
"@radix-ui/react-icons": "^1.3.1",
|
||||||
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.3",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
|
"@radix-ui/themes": "^3.1.5",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"ai": "^3.4.33",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"compare-versions": "^6.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cronstrue": "^2.48.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"electron-log": "^5.2.2",
|
||||||
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
|
"electron-updater": "^6.6.2",
|
||||||
|
"electron-window-state": "^5.0.3",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"lucide-react": "^0.475.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.3.0",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-router-dom": "^7.6.2",
|
||||||
|
"react-select": "^5.9.0",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"react-toastify": "^10.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"split-type": "^0.3.4",
|
||||||
|
"tailwind": "^4.0.0",
|
||||||
|
"tailwind-merge": "^2.5.4",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tw-animate-css": "^1.3.4",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.5.0",
|
"@electron-forge/cli": "^7.5.0",
|
||||||
"@electron-forge/maker-deb": "^7.5.0",
|
"@electron-forge/maker-deb": "^7.5.0",
|
||||||
@@ -50,11 +99,17 @@
|
|||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.51.1",
|
||||||
"@tailwindcss/line-clamp": "^0.4.4",
|
"@tailwindcss/line-clamp": "^0.4.4",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/electron": "^1.4.38",
|
"@types/electron": "^1.4.38",
|
||||||
"@types/electron-squirrel-startup": "^1.0.2",
|
"@types/electron-squirrel-startup": "^1.0.2",
|
||||||
"@types/electron-window-state": "^2.0.34",
|
"@types/electron-window-state": "^2.0.34",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/lodash": "^4.17.16",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@types/yauzl": "^2.10.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
@@ -67,7 +122,7 @@
|
|||||||
"lint-staged": "^15.4.1",
|
"lint-staged": "^15.4.1",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^4.1.10",
|
||||||
"typescript": "~5.5.0",
|
"typescript": "~5.5.0",
|
||||||
"vite": "^6.3.4"
|
"vite": "^6.3.4"
|
||||||
},
|
},
|
||||||
@@ -82,53 +137,5 @@
|
|||||||
"src/**/*.{css,json}": [
|
"src/**/*.{css,json}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/openai": "^0.0.72",
|
|
||||||
"@ai-sdk/ui-utils": "^1.0.2",
|
|
||||||
"@hey-api/client-fetch": "^0.8.1",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.2",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.7",
|
|
||||||
"@radix-ui/react-icons": "^1.3.1",
|
|
||||||
"@radix-ui/react-popover": "^1.1.6",
|
|
||||||
"@radix-ui/react-radio-group": "^1.2.3",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
|
||||||
"@radix-ui/react-select": "^2.1.7",
|
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
|
||||||
"@radix-ui/themes": "^3.1.5",
|
|
||||||
"@types/lodash": "^4.17.16",
|
|
||||||
"@types/react": "^18.3.12",
|
|
||||||
"@types/react-dom": "^18.3.1",
|
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
|
||||||
"@types/yauzl": "^2.10.3",
|
|
||||||
"ai": "^3.4.33",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"compare-versions": "^6.1.1",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"cronstrue": "^2.48.0",
|
|
||||||
"dotenv": "^16.4.5",
|
|
||||||
"electron-log": "^5.2.2",
|
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
|
||||||
"electron-updater": "^6.6.2",
|
|
||||||
"electron-window-state": "^5.0.3",
|
|
||||||
"express": "^4.21.1",
|
|
||||||
"framer-motion": "^11.11.11",
|
|
||||||
"lodash": "^4.17.21",
|
|
||||||
"lucide-react": "^0.475.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-icons": "^5.3.0",
|
|
||||||
"react-markdown": "^9.0.1",
|
|
||||||
"react-select": "^5.9.0",
|
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
|
||||||
"react-toastify": "^8.0.0",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"tailwind-merge": "^2.5.4",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"unist-util-visit": "^5.0.0",
|
|
||||||
"uuid": "^11.1.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ async function buildMain() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await build({
|
await build({
|
||||||
configFile: resolve(__dirname, '../vite.main.config.ts'),
|
configFile: resolve(__dirname, '../vite.main.config.mts'),
|
||||||
build: {
|
build: {
|
||||||
outDir,
|
outDir,
|
||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,7 @@ export type ListSchedulesResponse = {
|
|||||||
export type Message = {
|
export type Message = {
|
||||||
content: Array<MessageContent>;
|
content: Array<MessageContent>;
|
||||||
created: number;
|
created: number;
|
||||||
|
id?: string | null;
|
||||||
role: Role;
|
role: Role;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -428,6 +429,10 @@ export type SessionMetadata = {
|
|||||||
* The number of output tokens used in the session. Retrieved from the provider's last usage.
|
* The number of output tokens used in the session. Retrieved from the provider's last usage.
|
||||||
*/
|
*/
|
||||||
output_tokens?: number | null;
|
output_tokens?: number | null;
|
||||||
|
/**
|
||||||
|
* ID of the project this session belongs to, if any
|
||||||
|
*/
|
||||||
|
project_id?: string | null;
|
||||||
/**
|
/**
|
||||||
* ID of the schedule that triggered this session, if any
|
* ID of the schedule that triggered this session, if any
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ interface AgentHeaderProps {
|
|||||||
title: string;
|
title: string;
|
||||||
profileInfo?: string;
|
profileInfo?: string;
|
||||||
onChangeProfile?: () => void;
|
onChangeProfile?: () => void;
|
||||||
|
showBorder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentHeader({ title, profileInfo, onChangeProfile }: AgentHeaderProps) {
|
export function AgentHeader({
|
||||||
|
title,
|
||||||
|
profileInfo,
|
||||||
|
onChangeProfile,
|
||||||
|
showBorder = false,
|
||||||
|
}: AgentHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-borderSubtle">
|
<div
|
||||||
<div className="flex items-center">
|
className={`flex items-center justify-between px-4 py-2 ${showBorder ? 'border-b border-borderSubtle' : ''}`}
|
||||||
<span className="w-2 h-2 rounded-full bg-blockTeal mr-2" />
|
>
|
||||||
|
<div className="flex items-center ml-6">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500 mr-2" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
<span className="text-textSubtle">Agent</span>{' '}
|
<span className="text-textSubtle">Agent</span>{' '}
|
||||||
<span className="text-textStandard">{title}</span>
|
<span className="text-textStandard">{title}</span>
|
||||||
|
|||||||
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}
|
{error.message}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
<Button
|
<Button onClick={() => window.electron.reloadApp()}>Reload</Button>
|
||||||
className="flex items-center gap-2 flex-1 justify-center text-white dark:text-background bg-black dark:bg-foreground hover:bg-subtle dark:hover:bg-muted"
|
|
||||||
onClick={() => window.electron.reloadApp()}
|
|
||||||
>
|
|
||||||
Reload
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,21 @@ interface FileIconProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, className = "w-4 h-4" }) => {
|
export const FileIcon: React.FC<FileIconProps> = ({
|
||||||
|
fileName,
|
||||||
|
isDirectory,
|
||||||
|
className = 'w-4 h-4',
|
||||||
|
}) => {
|
||||||
if (isDirectory) {
|
if (isDirectory) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
className={className}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,12 +28,20 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
// Image files
|
// Image files
|
||||||
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext || '')) {
|
if (
|
||||||
|
['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif'].includes(ext || '')
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
className={className}
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
viewBox="0 0 24 24"
|
||||||
<polyline points="21,15 16,10 5,21"/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21,15 16,10 5,21" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -31,9 +49,15 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Video files
|
// Video files
|
||||||
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) {
|
if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<polygon points="23 7 16 12 23 17 23 7"/>
|
className={className}
|
||||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7" />
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,10 +65,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Audio files
|
// Audio files
|
||||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) {
|
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M9 18V5l12-2v13"/>
|
className={className}
|
||||||
<circle cx="6" cy="18" r="3"/>
|
viewBox="0 0 24 24"
|
||||||
<circle cx="18" cy="16" r="3"/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M9 18V5l12-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="16" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -52,12 +82,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Archive/compressed files
|
// Archive/compressed files
|
||||||
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) {
|
if (['zip', 'tar', 'gz', 'rar', '7z', 'bz2'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M16 22h2a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v3"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z"/>
|
fill="none"
|
||||||
<path d="M10 7h4"/>
|
stroke="currentColor"
|
||||||
<path d="M10 11h4"/>
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M16 22h2a2 2 0 0 0 2-2V7.5L14.5 2H6a2 2 0 0 0-2 2v3" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<path d="M10 20v-1a2 2 0 1 1 4 0v1a2 2 0 1 1-4 0Z" />
|
||||||
|
<path d="M10 7h4" />
|
||||||
|
<path d="M10 11h4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,12 +101,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// PDF files
|
// PDF files
|
||||||
if (ext === 'pdf') {
|
if (ext === 'pdf') {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<path d="M10 12h4"/>
|
fill="none"
|
||||||
<path d="M10 16h2"/>
|
stroke="currentColor"
|
||||||
<path d="M10 8h2"/>
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<path d="M10 12h4" />
|
||||||
|
<path d="M10 16h2" />
|
||||||
|
<path d="M10 8h2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,10 +120,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Design files
|
// Design files
|
||||||
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) {
|
if (['ai', 'eps', 'sketch', 'fig', 'xd', 'psd'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
className={className}
|
||||||
<path d="M9 9h6v6h-6z"/>
|
viewBox="0 0 24 24"
|
||||||
<path d="M16 3.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3z"/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<path d="M9 9h6v6h-6z" />
|
||||||
|
<path d="M16 3.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-3z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -89,10 +137,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// JavaScript/TypeScript files
|
// JavaScript/TypeScript files
|
||||||
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) {
|
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<path d="M10 13l2 2 4-4"/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<path d="M10 13l2 2 4-4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,12 +154,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Python files
|
// Python files
|
||||||
if (['py', 'pyw', 'pyc'].includes(ext || '')) {
|
if (['py', 'pyw', 'pyc'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<circle cx="10" cy="12" r="2"/>
|
fill="none"
|
||||||
<circle cx="14" cy="16" r="2"/>
|
stroke="currentColor"
|
||||||
<path d="M12 10c0-1 1-2 2-2s2 1 2 2-1 2-2 2"/>
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<circle cx="10" cy="12" r="2" />
|
||||||
|
<circle cx="14" cy="16" r="2" />
|
||||||
|
<path d="M12 10c0-1 1-2 2-2s2 1 2 2-1 2-2 2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,11 +173,17 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// HTML files
|
// HTML files
|
||||||
if (['html', 'htm', 'xhtml'].includes(ext || '')) {
|
if (['html', 'htm', 'xhtml'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<polyline points="9,13 9,17 15,17 15,13"/>
|
fill="none"
|
||||||
<line x1="12" y1="13" x2="12" y2="17"/>
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<polyline points="9,13 9,17 15,17 15,13" />
|
||||||
|
<line x1="12" y1="13" x2="12" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,12 +191,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// CSS files
|
// CSS files
|
||||||
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) {
|
if (['css', 'scss', 'sass', 'less', 'stylus'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<path d="M8 13h8"/>
|
fill="none"
|
||||||
<path d="M8 17h8"/>
|
stroke="currentColor"
|
||||||
<circle cx="12" cy="15" r="1"/>
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<path d="M8 13h8" />
|
||||||
|
<path d="M8 17h8" />
|
||||||
|
<circle cx="12" cy="15" r="1" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,11 +210,17 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// JSON/Data files
|
// JSON/Data files
|
||||||
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) {
|
if (['json', 'xml', 'yaml', 'yml', 'toml', 'csv'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<path d="M9 13v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"/>
|
fill="none"
|
||||||
<path d="M9 17v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"/>
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<path d="M9 13v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
|
||||||
|
<path d="M9 17v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,12 +228,18 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Markdown files
|
// Markdown files
|
||||||
if (['md', 'markdown', 'mdx'].includes(ext || '')) {
|
if (['md', 'markdown', 'mdx'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
fill="none"
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
stroke="currentColor"
|
||||||
<polyline points="10,9 9,9 8,9"/>
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10,9 9,9 8,9" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,35 +247,68 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Database files
|
// Database files
|
||||||
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) {
|
if (['sql', 'db', 'sqlite', 'sqlite3'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
className={className}
|
||||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
viewBox="0 0 24 24"
|
||||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||||
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||||
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration files
|
// Configuration files
|
||||||
if (['env', 'ini', 'cfg', 'conf', 'config', 'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc'].includes(ext || '') ||
|
if (
|
||||||
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())) {
|
[
|
||||||
|
'env',
|
||||||
|
'ini',
|
||||||
|
'cfg',
|
||||||
|
'conf',
|
||||||
|
'config',
|
||||||
|
'gitignore',
|
||||||
|
'dockerignore',
|
||||||
|
'editorconfig',
|
||||||
|
'prettierrc',
|
||||||
|
'eslintrc',
|
||||||
|
].includes(ext || '') ||
|
||||||
|
['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(fileName.toLowerCase())
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<circle cx="12" cy="12" r="3"/>
|
className={className}
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text files
|
// Text files
|
||||||
if (['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
|
if (
|
||||||
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())) {
|
['txt', 'log', 'readme', 'license', 'changelog', 'contributing'].includes(ext || '') ||
|
||||||
|
['readme', 'license', 'changelog', 'contributing'].includes(fileName.toLowerCase())
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
fill="none"
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
stroke="currentColor"
|
||||||
<polyline points="10,9 9,9 8,9"/>
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10,9 9,9 8,9" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,10 +316,16 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Executable files
|
// Executable files
|
||||||
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
|
if (['exe', 'app', 'deb', 'rpm', 'dmg', 'pkg', 'msi'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<polygon points="14 2 18 6 18 20 6 20 6 4 14 4"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<path d="M10 12l2 2 4-4"/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polygon points="14 2 18 6 18 20 6 20 6 4 14 4" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<path d="M10 12l2 2 4-4" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,21 +333,33 @@ export const FileIcon: React.FC<FileIconProps> = ({ fileName, isDirectory, class
|
|||||||
// Script files
|
// Script files
|
||||||
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) {
|
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'rb', 'pl', 'php'].includes(ext || '')) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<polyline points="4 17 10 11 4 5"/>
|
className={className}
|
||||||
<line x1="12" y1="19" x2="20" y2="19"/>
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="4 17 10 11 4 5" />
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default file icon
|
// Default file icon
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
className={className}
|
||||||
<polyline points="14,2 14,8 20,8"/>
|
viewBox="0 0 24 24"
|
||||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
fill="none"
|
||||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
stroke="currentColor"
|
||||||
<polyline points="10,9 9,9 8,9"/>
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10,9 9,9 8,9" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Goose, Rain } from './icons/Goose';
|
import { Goose, Rain } from './icons/Goose';
|
||||||
|
import { cn } from '../utils';
|
||||||
|
|
||||||
interface GooseLogoProps {
|
interface GooseLogoProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -28,12 +29,21 @@ export default function GooseLogo({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${className} ${currentSize.frame} ${hover ? 'group/with-hover' : ''} relative overflow-hidden`}
|
className={cn(
|
||||||
|
className,
|
||||||
|
currentSize.frame,
|
||||||
|
'relative overflow-hidden',
|
||||||
|
hover && 'group/with-hover'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Rain
|
<Rain
|
||||||
className={`${currentSize.rain} absolute left-0 bottom-0 ${hover ? 'opacity-0 group-hover/with-hover:opacity-100' : ''} transition-all duration-300 z-1`}
|
className={cn(
|
||||||
|
currentSize.rain,
|
||||||
|
'absolute left-0 bottom-0 transition-all duration-300 z-1',
|
||||||
|
hover && 'opacity-0 group-hover/with-hover:opacity-100'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Goose className={`${currentSize.goose} absolute left-0 bottom-0 z-2`} />
|
<Goose className={cn(currentSize.goose, 'absolute left-0 bottom-0 z-2')} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface GooseMessageProps {
|
|||||||
toolCallNotifications: Map<string, NotificationEvent[]>;
|
toolCallNotifications: Map<string, NotificationEvent[]>;
|
||||||
append: (value: string) => void;
|
append: (value: string) => void;
|
||||||
appendMessage: (message: Message) => void;
|
appendMessage: (message: Message) => void;
|
||||||
|
isStreaming?: boolean; // Whether this message is currently being streamed
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GooseMessage({
|
export default function GooseMessage({
|
||||||
@@ -39,8 +40,11 @@ export default function GooseMessage({
|
|||||||
toolCallNotifications,
|
toolCallNotifications,
|
||||||
append,
|
append,
|
||||||
appendMessage,
|
appendMessage,
|
||||||
|
isStreaming = false,
|
||||||
}: GooseMessageProps) {
|
}: GooseMessageProps) {
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Track which tool confirmations we've already handled to prevent infinite loops
|
||||||
|
const handledToolConfirmations = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// Extract text content from the message
|
// Extract text content from the message
|
||||||
let textContent = getTextContent(message);
|
let textContent = getTextContent(message);
|
||||||
@@ -115,17 +119,29 @@ export default function GooseMessage({
|
|||||||
if (
|
if (
|
||||||
messageIndex === messageHistoryIndex - 1 &&
|
messageIndex === messageHistoryIndex - 1 &&
|
||||||
hasToolConfirmation &&
|
hasToolConfirmation &&
|
||||||
toolConfirmationContent
|
toolConfirmationContent &&
|
||||||
|
!handledToolConfirmations.current.has(toolConfirmationContent.id)
|
||||||
) {
|
) {
|
||||||
|
// Only append the error message if there isn't already a response for this tool confirmation
|
||||||
|
const hasExistingResponse = messages.some((msg) =>
|
||||||
|
getToolResponses(msg).some((response) => response.id === toolConfirmationContent.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasExistingResponse) {
|
||||||
|
// Mark this tool confirmation as handled to prevent infinite loop
|
||||||
|
handledToolConfirmations.current.add(toolConfirmationContent.id);
|
||||||
|
|
||||||
appendMessage(
|
appendMessage(
|
||||||
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
|
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
messageIndex,
|
messageIndex,
|
||||||
messageHistoryIndex,
|
messageHistoryIndex,
|
||||||
hasToolConfirmation,
|
hasToolConfirmation,
|
||||||
toolConfirmationContent,
|
toolConfirmationContent,
|
||||||
|
messages,
|
||||||
appendMessage,
|
appendMessage,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -147,7 +163,7 @@ export default function GooseMessage({
|
|||||||
{/* Visible assistant response */}
|
{/* Visible assistant response */}
|
||||||
{displayText && (
|
{displayText && (
|
||||||
<div className="flex flex-col group">
|
<div className="flex flex-col group">
|
||||||
<div className={`goose-message-content pt-2`}>
|
<div className={`goose-message-content py-2`}>
|
||||||
<div ref={contentRef}>{<MarkdownContent content={displayText} />}</div>
|
<div ref={contentRef}>{<MarkdownContent content={displayText} />}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,14 +176,16 @@ export default function GooseMessage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Only show MessageCopyLink if there's text content and no tool requests/responses */}
|
{/* Only show timestamp and copy link when not streaming */}
|
||||||
<div className="relative flex justify-start">
|
<div className="relative flex justify-start">
|
||||||
{toolRequests.length === 0 && (
|
{toolRequests.length === 0 && !isStreaming && (
|
||||||
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
<div className="text-xs font-mono text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
||||||
{timestamp}
|
{timestamp}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{displayText && message.content.every((content) => content.type === 'text') && (
|
{displayText &&
|
||||||
|
message.content.every((content) => content.type === 'text') &&
|
||||||
|
!isStreaming && (
|
||||||
<div className="absolute left-0 pt-1">
|
<div className="absolute left-0 pt-1">
|
||||||
<MessageCopyLink text={displayText} contentRef={contentRef} />
|
<MessageCopyLink text={displayText} contentRef={contentRef} />
|
||||||
</div>
|
</div>
|
||||||
@@ -179,10 +197,7 @@ export default function GooseMessage({
|
|||||||
{toolRequests.length > 0 && (
|
{toolRequests.length > 0 && (
|
||||||
<div className="relative flex flex-col w-full">
|
<div className="relative flex flex-col w-full">
|
||||||
{toolRequests.map((toolRequest) => (
|
{toolRequests.map((toolRequest) => (
|
||||||
<div
|
<div className={`goose-message-tool pb-2`} key={toolRequest.id}>
|
||||||
className={`goose-message-tool bg-bgSubtle rounded px-2 py-2 mb-2`}
|
|
||||||
key={toolRequest.id}
|
|
||||||
>
|
|
||||||
<ToolCallWithResponse
|
<ToolCallWithResponse
|
||||||
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
|
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
|
||||||
isCancelledMessage={
|
isCancelledMessage={
|
||||||
@@ -192,11 +207,12 @@ export default function GooseMessage({
|
|||||||
toolRequest={toolRequest}
|
toolRequest={toolRequest}
|
||||||
toolResponse={toolResponsesMap.get(toolRequest.id)}
|
toolResponse={toolResponsesMap.get(toolRequest.id)}
|
||||||
notifications={toolCallNotifications.get(toolRequest.id)}
|
notifications={toolCallNotifications.get(toolRequest.id)}
|
||||||
|
isStreamingMessage={isStreaming}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
<div className="text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
||||||
{timestamp}
|
{!isStreaming && timestamp}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -147,11 +147,7 @@ export default function GooseResponseForm({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{isQuestion && !isOptions && !isForm(dynamicForm) && (
|
{isQuestion && !isOptions && !isForm(dynamicForm) && (
|
||||||
<div className="flex items-center gap-4 p-4 rounded-lg bg-tool-card dark:bg-tool-card-dark border dark:border-dark-border">
|
<div className="flex items-center gap-4 p-4 rounded-lg bg-tool-card dark:bg-tool-card-dark border dark:border-dark-border">
|
||||||
<Button
|
<Button onClick={handleAccept} className="w-full sm:w-auto">
|
||||||
onClick={handleAccept}
|
|
||||||
variant="default"
|
|
||||||
className="w-full sm:w-auto dark:bg-button-dark"
|
|
||||||
>
|
|
||||||
<Send className="h-[14px] w-[14px]" />
|
<Send className="h-[14px] w-[14px]" />
|
||||||
Take flight with this plan
|
Take flight with this plan
|
||||||
</Button>
|
</Button>
|
||||||
@@ -230,11 +226,7 @@ export default function GooseResponseForm({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Button
|
<Button type="submit" className="w-full sm:w-auto mt-4">
|
||||||
type="submit"
|
|
||||||
variant="default"
|
|
||||||
className="w-full sm:w-auto mt-4 dark:bg-button-dark"
|
|
||||||
>
|
|
||||||
<Send className="h-[14px] w-[14px]" />
|
<Send className="h-[14px] w-[14px]" />
|
||||||
Submit Form
|
Submit Form
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
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 { useState, useEffect } from 'react';
|
||||||
import { Card } from './ui/card';
|
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Check } from './icons';
|
import { Check } from './icons';
|
||||||
|
import {
|
||||||
const Modal = ({ children }: { children: React.ReactNode }) => (
|
Dialog,
|
||||||
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] z-[1000]">
|
DialogContent,
|
||||||
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col min-w-[80%] min-h-[80%] bg-bgApp rounded-xl overflow-hidden shadow-none px-8 pt-[24px] pb-0">
|
DialogDescription,
|
||||||
<div className="flex flex-col flex-1 space-y-8 text-base text-textStandard h-full">
|
DialogFooter,
|
||||||
{children}
|
DialogHeader,
|
||||||
</div>
|
DialogTitle,
|
||||||
</Card>
|
} from './ui/dialog';
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ModalHeader = () => (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="flex">
|
|
||||||
<h2 className="text-2xl font-regular text-textStandard">Configure .goosehints</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ModalHelpText = () => (
|
const ModalHelpText = () => (
|
||||||
<div className="text-sm flex-col space-y-4">
|
<div className="text-sm flex-col space-y-4">
|
||||||
@@ -66,27 +55,6 @@ const ModalFileInfo = ({ filePath, found }: { filePath: string; found: boolean }
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ModalButtons = ({ onSubmit, onCancel }: { onSubmit: () => void; onCancel: () => void }) => (
|
|
||||||
<div className="-ml-8 -mr-8">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onSubmit}
|
|
||||||
className="w-full h-[60px] rounded-none border-t border-borderSubtle text-base hover:bg-bgSubtle text-textProminent font-regular"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="w-full h-[60px] rounded-none border-t border-borderSubtle hover:text-textStandard text-textSubtle hover:bg-bgSubtle text-base font-regular"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const getGoosehintsFile = async (filePath: string) => await window.electron.readFile(filePath);
|
const getGoosehintsFile = async (filePath: string) => await window.electron.readFile(filePath);
|
||||||
|
|
||||||
type GoosehintsModalProps = {
|
type GoosehintsModalProps = {
|
||||||
@@ -122,26 +90,45 @@ export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: Goosehi
|
|||||||
setIsGoosehintsModalOpen(false);
|
setIsGoosehintsModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsGoosehintsModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal>
|
<Dialog open={true} onOpenChange={handleClose}>
|
||||||
<ModalHeader />
|
<DialogContent className="sm:max-w-[80%] sm:max-h-[80%] overflow-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configure .goosehints</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure your project's .goosehints file to provide additional context to Goose.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<ModalHelpText />
|
<ModalHelpText />
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
|
<div className="py-4">
|
||||||
{goosehintsFileReadError ? (
|
{goosehintsFileReadError ? (
|
||||||
<ModalError error={new Error(goosehintsFileReadError)} />
|
<ModalError error={new Error(goosehintsFileReadError)} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col flex-1 space-y-2 h-full">
|
<div className="space-y-2">
|
||||||
<ModalFileInfo filePath={goosehintsFilePath} found={goosehintsFileFound} />
|
<ModalFileInfo filePath={goosehintsFilePath} found={goosehintsFileFound} />
|
||||||
<textarea
|
<textarea
|
||||||
defaultValue={goosehintsFile}
|
defaultValue={goosehintsFile}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full flex-1 border rounded-md min-h-20 p-2 text-sm resize-none bg-bgApp text-textStandard border-borderStandard focus:outline-none"
|
className="w-full h-80 border rounded-md p-2 text-sm resize-none bg-background-default text-textStandard border-borderStandard focus:outline-none"
|
||||||
onChange={(event) => setGoosehintsFile(event.target.value)}
|
onChange={(event) => setGoosehintsFile(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ModalButtons onSubmit={writeFile} onCancel={() => setIsGoosehintsModalOpen(false)} />
|
|
||||||
</Modal>
|
<DialogFooter className="pt-2">
|
||||||
|
<Button variant="outline" onClick={handleClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={writeFile}>Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export default function LayingEggLoader() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 bg-bgApp">
|
<div className="fixed inset-0 flex items-center justify-center z-50 bg-background-default">
|
||||||
<div className="flex flex-col items-center max-w-3xl w-full px-6 pt-10">
|
<div className="flex flex-col items-center max-w-3xl w-full px-6 pt-10">
|
||||||
<div className="w-16 h-16 bg-bgApp rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 bg-background-default rounded-full flex items-center justify-center mb-4">
|
||||||
<Geese className="w-12 h-12 text-iconProminent" />
|
<Geese className="w-12 h-12 text-iconProminent" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-medium text-center mb-2 text-textProminent">
|
<h1 className="text-2xl font-medium text-center mb-2 text-textProminent">
|
||||||
|
|||||||
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';
|
import GooseLogo from './GooseLogo';
|
||||||
|
|
||||||
const LoadingGoose = () => {
|
interface LoadingGooseProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingGoose = ({ message = 'goose is working on it…' }: LoadingGooseProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full pb-[2px]">
|
<div className="w-full animate-fade-slide-up">
|
||||||
<div
|
<div
|
||||||
data-testid="loading-indicator"
|
data-testid="loading-indicator"
|
||||||
className="flex items-center text-xs text-textStandard mb-2 mt-2 animate-[appear_250ms_ease-in_forwards]"
|
className="flex items-center gap-2 text-xs text-textStandard py-2"
|
||||||
>
|
>
|
||||||
<GooseLogo className="mr-2" size="small" hover={false} />
|
<GooseLogo size="small" hover={false} />
|
||||||
goose is working on it…
|
{message}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -112,17 +112,17 @@ export default function MarkdownContent({ content, className = '' }: MarkdownCon
|
|||||||
<div className="w-full overflow-x-hidden">
|
<div className="w-full overflow-x-hidden">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
className={`prose prose-sm text-textStandard dark:prose-invert w-full max-w-full word-break
|
className={`prose prose-sm text-text-default dark:prose-invert w-full max-w-full word-break
|
||||||
prose-pre:p-0 prose-pre:m-0 !p-0
|
prose-pre:p-0 prose-pre:m-0 !p-0
|
||||||
prose-code:break-all prose-code:whitespace-pre-wrap
|
prose-code:break-all prose-code:whitespace-pre-wrap
|
||||||
prose-table:table prose-table:w-full
|
prose-table:table prose-table:w-full
|
||||||
prose-blockquote:text-inherit
|
prose-blockquote:text-inherit
|
||||||
prose-td:border prose-td:border-borderSubtle prose-td:p-2
|
prose-td:border prose-td:border-border-default prose-td:p-2
|
||||||
prose-th:border prose-th:border-borderSubtle prose-th:p-2
|
prose-th:border prose-th:border-border-default prose-th:p-2
|
||||||
prose-thead:bg-bgSubtle
|
prose-thead:bg-background-default
|
||||||
prose-h1:text-2xl prose-h1:font-medium prose-h1:mb-5 prose-h1:mt-5
|
prose-h1:text-2xl prose-h1:font-normal prose-h1:mb-5 prose-h1:mt-0
|
||||||
prose-h2:text-xl prose-h2:font-medium prose-h2:mb-4 prose-h2:mt-4
|
prose-h2:text-xl prose-h2:font-normal prose-h2:mb-4 prose-h2:mt-4
|
||||||
prose-h3:text-lg prose-h3:font-medium prose-h3:mb-3 prose-h3:mt-3
|
prose-h3:text-lg prose-h3:font-normal prose-h3:mb-3 prose-h3:mt-3
|
||||||
prose-p:mt-0 prose-p:mb-2
|
prose-p:mt-0 prose-p:mb-2
|
||||||
prose-ol:my-2
|
prose-ol:my-2
|
||||||
prose-ul:mt-0 prose-ul:mb-3
|
prose-ul:mt-0 prose-ul:mb-3
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
import { FileIcon } from './FileIcon';
|
import { FileIcon } from './FileIcon';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
@@ -46,7 +54,13 @@ const fuzzyMatch = (pattern: string, text: string): { score: number; matches: nu
|
|||||||
score += consecutiveMatches * 3;
|
score += consecutiveMatches * 3;
|
||||||
|
|
||||||
// Bonus for matches at word boundaries or path separators
|
// Bonus for matches at word boundaries or path separators
|
||||||
if (i === 0 || textLower[i - 1] === '/' || textLower[i - 1] === '_' || textLower[i - 1] === '-' || textLower[i - 1] === '.') {
|
if (
|
||||||
|
i === 0 ||
|
||||||
|
textLower[i - 1] === '/' ||
|
||||||
|
textLower[i - 1] === '_' ||
|
||||||
|
textLower[i - 1] === '-' ||
|
||||||
|
textLower[i - 1] === '.'
|
||||||
|
) {
|
||||||
score += 10;
|
score += 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,15 +99,7 @@ const fuzzyMatch = (pattern: string, text: string): { score: number; matches: nu
|
|||||||
const MentionPopover = forwardRef<
|
const MentionPopover = forwardRef<
|
||||||
{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void },
|
{ getDisplayFiles: () => FileItemWithMatch[]; selectFile: (index: number) => void },
|
||||||
MentionPopoverProps
|
MentionPopoverProps
|
||||||
>(({
|
>(({ isOpen, onClose, onSelect, position, query, selectedIndex, onSelectedIndexChange }, ref) => {
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSelect,
|
|
||||||
position,
|
|
||||||
query,
|
|
||||||
selectedIndex,
|
|
||||||
onSelectedIndexChange
|
|
||||||
}, ref) => {
|
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -102,16 +108,16 @@ const MentionPopover = forwardRef<
|
|||||||
// Filter and sort files based on query
|
// Filter and sort files based on query
|
||||||
const displayFiles = useMemo((): FileItemWithMatch[] => {
|
const displayFiles = useMemo((): FileItemWithMatch[] => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return files.slice(0, 15).map(file => ({
|
return files.slice(0, 15).map((file) => ({
|
||||||
...file,
|
...file,
|
||||||
matchScore: 0,
|
matchScore: 0,
|
||||||
matches: [],
|
matches: [],
|
||||||
matchedText: file.name
|
matchedText: file.name,
|
||||||
})); // Show first 15 files when no query
|
})); // Show first 15 files when no query
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = files
|
const results = files
|
||||||
.map(file => {
|
.map((file) => {
|
||||||
const nameMatch = fuzzyMatch(query, file.name);
|
const nameMatch = fuzzyMatch(query, file.name);
|
||||||
const pathMatch = fuzzyMatch(query, file.relativePath);
|
const pathMatch = fuzzyMatch(query, file.relativePath);
|
||||||
const fullPathMatch = fuzzyMatch(query, file.path);
|
const fullPathMatch = fuzzyMatch(query, file.path);
|
||||||
@@ -134,10 +140,10 @@ const MentionPopover = forwardRef<
|
|||||||
...file,
|
...file,
|
||||||
matchScore: bestMatch.score,
|
matchScore: bestMatch.score,
|
||||||
matches: bestMatch.matches,
|
matches: bestMatch.matches,
|
||||||
matchedText
|
matchedText,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(file => file.matchScore > 0)
|
.filter((file) => file.matchScore > 0)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Sort by score first, then prefer files over directories, then alphabetically
|
// Sort by score first, then prefer files over directories, then alphabetically
|
||||||
if (Math.abs(a.matchScore - b.matchScore) < 1) {
|
if (Math.abs(a.matchScore - b.matchScore) < 1) {
|
||||||
@@ -154,15 +160,19 @@ const MentionPopover = forwardRef<
|
|||||||
}, [files, query]);
|
}, [files, query]);
|
||||||
|
|
||||||
// Expose methods to parent component
|
// Expose methods to parent component
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
getDisplayFiles: () => displayFiles,
|
getDisplayFiles: () => displayFiles,
|
||||||
selectFile: (index: number) => {
|
selectFile: (index: number) => {
|
||||||
if (displayFiles[index]) {
|
if (displayFiles[index]) {
|
||||||
onSelect(displayFiles[index].path);
|
onSelect(displayFiles[index].path);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}), [displayFiles, onSelect, onClose]);
|
}),
|
||||||
|
[displayFiles, onSelect, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
// Scan files when component opens
|
// Scan files when component opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -189,7 +199,8 @@ const MentionPopover = forwardRef<
|
|||||||
};
|
};
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
const scanDirectoryFromRoot = useCallback(async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
|
const scanDirectoryFromRoot = useCallback(
|
||||||
|
async (dirPath: string, relativePath = '', depth = 0): Promise<FileItem[]> => {
|
||||||
// Increase depth limit for better file discovery
|
// Increase depth limit for better file discovery
|
||||||
if (depth > 5) return [];
|
if (depth > 5) return [];
|
||||||
|
|
||||||
@@ -198,15 +209,40 @@ const MentionPopover = forwardRef<
|
|||||||
const results: FileItem[] = [];
|
const results: FileItem[] = [];
|
||||||
|
|
||||||
// Common directories to prioritize or skip
|
// Common directories to prioritize or skip
|
||||||
const priorityDirs = ['Desktop', 'Documents', 'Downloads', 'Projects', 'Development', 'Code', 'src', 'components', 'icons'];
|
const priorityDirs = [
|
||||||
|
'Desktop',
|
||||||
|
'Documents',
|
||||||
|
'Downloads',
|
||||||
|
'Projects',
|
||||||
|
'Development',
|
||||||
|
'Code',
|
||||||
|
'src',
|
||||||
|
'components',
|
||||||
|
'icons',
|
||||||
|
];
|
||||||
const skipDirs = [
|
const skipDirs = [
|
||||||
'.git', '.svn', '.hg', 'node_modules', '__pycache__', '.vscode', '.idea',
|
'.git',
|
||||||
'target', 'dist', 'build', '.cache', '.npm', '.yarn', 'Library',
|
'.svn',
|
||||||
'System', 'Applications', '.Trash'
|
'.hg',
|
||||||
|
'node_modules',
|
||||||
|
'__pycache__',
|
||||||
|
'.vscode',
|
||||||
|
'.idea',
|
||||||
|
'target',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'.cache',
|
||||||
|
'.npm',
|
||||||
|
'.yarn',
|
||||||
|
'Library',
|
||||||
|
'System',
|
||||||
|
'Applications',
|
||||||
|
'.Trash',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Don't skip as many directories at deeper levels to find more files
|
// Don't skip as many directories at deeper levels to find more files
|
||||||
const skipDirsAtDepth = depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
|
const skipDirsAtDepth =
|
||||||
|
depth > 2 ? ['.git', '.svn', '.hg', 'node_modules', '__pycache__'] : skipDirs;
|
||||||
|
|
||||||
// Sort items to prioritize certain directories
|
// Sort items to prioritize certain directories
|
||||||
const sortedItems = items.sort((a, b) => {
|
const sortedItems = items.sort((a, b) => {
|
||||||
@@ -234,20 +270,81 @@ const MentionPopover = forwardRef<
|
|||||||
const ext = item.split('.').pop()?.toLowerCase();
|
const ext = item.split('.').pop()?.toLowerCase();
|
||||||
const commonExtensions = [
|
const commonExtensions = [
|
||||||
// Code files
|
// Code files
|
||||||
'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'h',
|
'txt',
|
||||||
'css', 'html', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg',
|
'md',
|
||||||
'sh', 'bat', 'ps1', 'rb', 'go', 'rs', 'php', 'sql', 'r', 'scala',
|
'js',
|
||||||
'swift', 'kt', 'dart', 'vue', 'svelte', 'astro', 'scss', 'less',
|
'ts',
|
||||||
|
'jsx',
|
||||||
|
'tsx',
|
||||||
|
'py',
|
||||||
|
'java',
|
||||||
|
'cpp',
|
||||||
|
'c',
|
||||||
|
'h',
|
||||||
|
'css',
|
||||||
|
'html',
|
||||||
|
'json',
|
||||||
|
'xml',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'toml',
|
||||||
|
'ini',
|
||||||
|
'cfg',
|
||||||
|
'sh',
|
||||||
|
'bat',
|
||||||
|
'ps1',
|
||||||
|
'rb',
|
||||||
|
'go',
|
||||||
|
'rs',
|
||||||
|
'php',
|
||||||
|
'sql',
|
||||||
|
'r',
|
||||||
|
'scala',
|
||||||
|
'swift',
|
||||||
|
'kt',
|
||||||
|
'dart',
|
||||||
|
'vue',
|
||||||
|
'svelte',
|
||||||
|
'astro',
|
||||||
|
'scss',
|
||||||
|
'less',
|
||||||
// Documentation
|
// Documentation
|
||||||
'readme', 'license', 'changelog', 'contributing',
|
'readme',
|
||||||
|
'license',
|
||||||
|
'changelog',
|
||||||
|
'contributing',
|
||||||
// Config files
|
// Config files
|
||||||
'gitignore', 'dockerignore', 'editorconfig', 'prettierrc', 'eslintrc',
|
'gitignore',
|
||||||
|
'dockerignore',
|
||||||
|
'editorconfig',
|
||||||
|
'prettierrc',
|
||||||
|
'eslintrc',
|
||||||
// Images and assets
|
// Images and assets
|
||||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'tiff', 'tif',
|
'png',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'gif',
|
||||||
|
'svg',
|
||||||
|
'ico',
|
||||||
|
'webp',
|
||||||
|
'bmp',
|
||||||
|
'tiff',
|
||||||
|
'tif',
|
||||||
// Vector and design files
|
// Vector and design files
|
||||||
'ai', 'eps', 'sketch', 'fig', 'xd', 'psd',
|
'ai',
|
||||||
|
'eps',
|
||||||
|
'sketch',
|
||||||
|
'fig',
|
||||||
|
'xd',
|
||||||
|
'psd',
|
||||||
// Other common files
|
// Other common files
|
||||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'
|
'pdf',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
];
|
];
|
||||||
|
|
||||||
// If it has a known file extension, treat it as a file
|
// If it has a known file extension, treat it as a file
|
||||||
@@ -256,19 +353,26 @@ const MentionPopover = forwardRef<
|
|||||||
path: fullPath,
|
path: fullPath,
|
||||||
name: item,
|
name: item,
|
||||||
isDirectory: false,
|
isDirectory: false,
|
||||||
relativePath: itemRelativePath
|
relativePath: itemRelativePath,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a known file without extension (README, LICENSE, etc.)
|
// If it's a known file without extension (README, LICENSE, etc.)
|
||||||
const knownFiles = ['readme', 'license', 'changelog', 'contributing', 'dockerfile', 'makefile'];
|
const knownFiles = [
|
||||||
|
'readme',
|
||||||
|
'license',
|
||||||
|
'changelog',
|
||||||
|
'contributing',
|
||||||
|
'dockerfile',
|
||||||
|
'makefile',
|
||||||
|
];
|
||||||
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
|
if (!hasExtension && knownFiles.includes(item.toLowerCase())) {
|
||||||
results.push({
|
results.push({
|
||||||
path: fullPath,
|
path: fullPath,
|
||||||
name: item,
|
name: item,
|
||||||
isDirectory: false,
|
isDirectory: false,
|
||||||
relativePath: itemRelativePath
|
relativePath: itemRelativePath,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -282,7 +386,7 @@ const MentionPopover = forwardRef<
|
|||||||
path: fullPath,
|
path: fullPath,
|
||||||
name: item,
|
name: item,
|
||||||
isDirectory: true,
|
isDirectory: true,
|
||||||
relativePath: itemRelativePath
|
relativePath: itemRelativePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recursively scan directories more aggressively
|
// Recursively scan directories more aggressively
|
||||||
@@ -301,7 +405,9 @@ const MentionPopover = forwardRef<
|
|||||||
console.error(`Error scanning directory ${dirPath}:`, error);
|
console.error(`Error scanning directory ${dirPath}:`, error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const scanFilesFromRoot = useCallback(async () => {
|
const scanFilesFromRoot = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -348,7 +454,7 @@ const MentionPopover = forwardRef<
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
className="fixed z-50 bg-bgApp border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg"
|
className="fixed z-50 bg-background-default border border-borderStandard rounded-lg shadow-lg min-w-96 max-w-lg"
|
||||||
style={{
|
style={{
|
||||||
left: position.x,
|
left: position.x,
|
||||||
top: position.y - 10, // Position above the chat input
|
top: position.y - 10, // Position above the chat input
|
||||||
@@ -378,12 +484,8 @@ const MentionPopover = forwardRef<
|
|||||||
<FileIcon fileName={file.name} isDirectory={file.isDirectory} />
|
<FileIcon fileName={file.name} isDirectory={file.isDirectory} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm truncate text-textStandard">
|
<div className="text-sm truncate text-textStandard">{file.name}</div>
|
||||||
{file.name}
|
<div className="text-xs text-textSubtle truncate">{file.path}</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-textSubtle truncate">
|
|
||||||
{file.path}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkPro
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="flex items-center gap-1 text-xs text-textSubtle hover:cursor-pointer hover:text-textProminent transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0"
|
className="flex font-mono items-center gap-1 text-xs text-textSubtle hover:cursor-pointer hover:text-textProminent transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0"
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
<span>{copied ? 'Copied!' : 'Copy'}</span>
|
<span>{copied ? 'Copied!' : 'Copy'}</span>
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ export default function Modal({
|
|||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
className="relative w-[500px] max-w-full bg-bgApp rounded-xl my-10 max-h-[90vh] flex flex-col shadow-xl z-[10000]"
|
className="relative w-[500px] max-w-full bg-background-default rounded-xl my-10 max-h-[90vh] flex flex-col shadow-xl z-[10000]"
|
||||||
>
|
>
|
||||||
<div className="p-8 max-h-[calc(90vh-180px)] overflow-y-auto">{children}</div>
|
<div className="p-8 max-h-[calc(90vh-180px)] overflow-y-auto">{children}</div>
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="border-t border-borderSubtle bg-bgApp w-full rounded-b-xl overflow-hidden">
|
<div className="border-t border-borderSubtle bg-background-default w-full rounded-b-xl overflow-hidden">
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { toastError, toastSuccess } from '../toasts';
|
|||||||
import Model, { getProviderMetadata } from './settings/models/modelInterface';
|
import Model, { getProviderMetadata } from './settings/models/modelInterface';
|
||||||
import { ProviderMetadata } from '../api';
|
import { ProviderMetadata } from '../api';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
|
import {
|
||||||
|
getModelDisplayName,
|
||||||
|
getProviderDisplayName,
|
||||||
|
} from './settings/models/predefinedModelsUtils';
|
||||||
|
|
||||||
// titles
|
// titles
|
||||||
export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup';
|
export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup';
|
||||||
@@ -26,6 +30,8 @@ interface ModelAndProviderContextType {
|
|||||||
getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>;
|
getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>;
|
||||||
getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>;
|
getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>;
|
||||||
getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>;
|
getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>;
|
||||||
|
getCurrentModelDisplayName: () => Promise<string>;
|
||||||
|
getCurrentProviderDisplayName: () => Promise<string>; // Gets provider display name from subtext
|
||||||
refreshCurrentModelAndProvider: () => Promise<void>;
|
refreshCurrentModelAndProvider: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +144,30 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
|||||||
return { model: gooseModel, provider: providerDisplayName };
|
return { model: gooseModel, provider: providerDisplayName };
|
||||||
}, [getCurrentModelAndProvider, getProviders]);
|
}, [getCurrentModelAndProvider, getProviders]);
|
||||||
|
|
||||||
|
const getCurrentModelDisplayName = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const currentModelName = (await read('GOOSE_MODEL', false)) as string;
|
||||||
|
return getModelDisplayName(currentModelName);
|
||||||
|
} catch (error) {
|
||||||
|
return 'Select Model';
|
||||||
|
}
|
||||||
|
}, [read]);
|
||||||
|
|
||||||
|
const getCurrentProviderDisplayName = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const currentModelName = (await read('GOOSE_MODEL', false)) as string;
|
||||||
|
const providerDisplayName = getProviderDisplayName(currentModelName);
|
||||||
|
if (providerDisplayName) {
|
||||||
|
return providerDisplayName;
|
||||||
|
}
|
||||||
|
// Fall back to regular provider display name lookup
|
||||||
|
const { provider } = await getCurrentModelAndProviderForDisplay();
|
||||||
|
return provider;
|
||||||
|
} catch (error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [read, getCurrentModelAndProviderForDisplay]);
|
||||||
|
|
||||||
const refreshCurrentModelAndProvider = useCallback(async () => {
|
const refreshCurrentModelAndProvider = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { model, provider } = await getCurrentModelAndProvider();
|
const { model, provider } = await getCurrentModelAndProvider();
|
||||||
@@ -161,6 +191,8 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
|||||||
getCurrentModelAndProvider,
|
getCurrentModelAndProvider,
|
||||||
getFallbackModelAndProvider,
|
getFallbackModelAndProvider,
|
||||||
getCurrentModelAndProviderForDisplay,
|
getCurrentModelAndProviderForDisplay,
|
||||||
|
getCurrentModelDisplayName,
|
||||||
|
getCurrentProviderDisplayName,
|
||||||
refreshCurrentModelAndProvider,
|
refreshCurrentModelAndProvider,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -170,6 +202,8 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
|
|||||||
getCurrentModelAndProvider,
|
getCurrentModelAndProvider,
|
||||||
getFallbackModelAndProvider,
|
getFallbackModelAndProvider,
|
||||||
getCurrentModelAndProviderForDisplay,
|
getCurrentModelAndProviderForDisplay,
|
||||||
|
getCurrentModelDisplayName,
|
||||||
|
getCurrentProviderDisplayName,
|
||||||
refreshCurrentModelAndProvider,
|
refreshCurrentModelAndProvider,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
|
|||||||
<div className="fixed inset-0 backdrop-blur-sm z-50 flex justify-center items-center animate-[fadein_200ms_ease-in]">
|
<div className="fixed inset-0 backdrop-blur-sm z-50 flex justify-center items-center animate-[fadein_200ms_ease-in]">
|
||||||
{showCancelOptions ? (
|
{showCancelOptions ? (
|
||||||
// Cancel options modal
|
// Cancel options modal
|
||||||
<div className="bg-bgApp border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-md">
|
<div className="bg-background-default border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-md">
|
||||||
<h2 className="text-xl font-bold text-textProminent mb-4">Cancel Recipe Setup</h2>
|
<h2 className="text-xl font-bold text-textProminent mb-4">Cancel Recipe Setup</h2>
|
||||||
<p className="text-textStandard mb-6">What would you like to do?</p>
|
<p className="text-textStandard mb-6">What would you like to do?</p>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -113,7 +113,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Main parameter form
|
// Main parameter form
|
||||||
<div className="bg-bgApp border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-lg">
|
<div className="bg-background-default border border-borderSubtle rounded-xl p-8 shadow-2xl w-full max-w-lg">
|
||||||
<h2 className="text-xl font-bold text-textProminent mb-6">Recipe Parameters</h2>
|
<h2 className="text-xl font-bold text-textProminent mb-6">Recipe Parameters</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{parameters.map((param) => (
|
{parameters.map((param) => (
|
||||||
|
|||||||
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 { useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
export default function RecipeActivityEditor({
|
export default function RecipeActivityEditor({
|
||||||
activities,
|
activities,
|
||||||
@@ -31,16 +32,18 @@ export default function RecipeActivityEditor({
|
|||||||
{activities.map((activity, index) => (
|
{activities.map((activity, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="inline-flex items-center bg-bgApp border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
|
className="inline-flex items-center bg-background-default border-2 border-borderSubtle rounded-full px-4 py-2 text-sm text-textStandard"
|
||||||
title={activity.length > 100 ? activity : undefined}
|
title={activity.length > 100 ? activity : undefined}
|
||||||
>
|
>
|
||||||
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
|
<span>{activity.length > 100 ? activity.slice(0, 100) + '...' : activity}</span>
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleRemoveActivity(activity)}
|
onClick={() => handleRemoveActivity(activity)}
|
||||||
className="ml-2 text-textStandard hover:text-textSubtle transition-colors"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-2 text-textStandard hover:text-textSubtle transition-colors p-0 h-auto"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -50,15 +53,15 @@ export default function RecipeActivityEditor({
|
|||||||
value={newActivity}
|
value={newActivity}
|
||||||
onChange={(e) => setNewActivity(e.target.value)}
|
onChange={(e) => setNewActivity(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
|
onKeyPress={(e) => e.key === 'Enter' && handleAddActivity()}
|
||||||
className="flex-1 px-4 py-3 border rounded-lg bg-bgApp text-textStandard placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
className="flex-1 px-4 py-3 border rounded-lg bg-background-default text-textStandard placeholder-textPlaceholder focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
||||||
placeholder="Add new activity..."
|
placeholder="Add new activity..."
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
onClick={handleAddActivity}
|
onClick={handleAddActivity}
|
||||||
className="px-5 py-1.5 text-sm bg-bgAppInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
className="px-5 py-1.5 text-sm bg-background-defaultInverse text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
||||||
>
|
>
|
||||||
Add activity
|
Add activity
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Recipe } from '../recipe';
|
import { Recipe } from '../recipe';
|
||||||
import { Parameter } from '../recipe/index';
|
import { Parameter } from '../recipe/index';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
|
|||||||
import ParameterInput from './parameter/ParameterInput';
|
import ParameterInput from './parameter/ParameterInput';
|
||||||
import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
|
import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
|
||||||
import { toastSuccess, toastError } from '../toasts';
|
import { toastSuccess, toastError } from '../toasts';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
interface RecipeEditorProps {
|
interface RecipeEditorProps {
|
||||||
config?: Recipe;
|
config?: Recipe;
|
||||||
@@ -30,6 +32,7 @@ function generateDeepLink(recipe: Recipe): string {
|
|||||||
|
|
||||||
export default function RecipeEditor({ config }: RecipeEditorProps) {
|
export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||||
const { getExtensions } = useConfig();
|
const { getExtensions } = useConfig();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [recipeConfig] = useState<Recipe | undefined>(config);
|
const [recipeConfig] = useState<Recipe | undefined>(config);
|
||||||
const [title, setTitle] = useState(config?.title || '');
|
const [title, setTitle] = useState(config?.title || '');
|
||||||
const [description, setDescription] = useState(config?.description || '');
|
const [description, setDescription] = useState(config?.description || '');
|
||||||
@@ -321,10 +324,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-screen bg-bgApp max-w-3xl mx-auto">
|
<div className="flex flex-col w-full h-screen bg-background-default">
|
||||||
{activeSection === 'none' && (
|
{activeSection === 'none' && (
|
||||||
<div className="flex flex-col items-center mb-2 px-6 pt-10">
|
<div className="flex flex-col items-center mb-2 px-6 pt-10">
|
||||||
<div className="w-16 h-16 bg-bgApp rounded-full flex items-center justify-center mb-4">
|
<div className="w-16 h-16 bg-background-default rounded-full flex items-center justify-center mb-4">
|
||||||
<Geese className="w-12 h-12 text-iconProminent" />
|
<Geese className="w-12 h-12 text-iconProminent" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-medium text-center text-textProminent">{page_title}</h1>
|
<h1 className="text-2xl font-medium text-center text-textProminent">{page_title}</h1>
|
||||||
@@ -349,7 +352,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
setErrors({ ...errors, title: undefined });
|
setErrors({ ...errors, title: undefined });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
|
className={`w-full max-w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent overflow-hidden ${
|
||||||
errors.title ? 'border-red-500' : 'border-borderSubtle'
|
errors.title ? 'border-red-500' : 'border-borderSubtle'
|
||||||
}`}
|
}`}
|
||||||
placeholder="Agent Recipe Title (required)"
|
placeholder="Agent Recipe Title (required)"
|
||||||
@@ -372,7 +375,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
setErrors({ ...errors, description: undefined });
|
setErrors({ ...errors, description: undefined });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent ${
|
className={`w-full max-w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent overflow-hidden ${
|
||||||
errors.description ? 'border-red-500' : 'border-borderSubtle'
|
errors.description ? 'border-red-500' : 'border-borderSubtle'
|
||||||
}`}
|
}`}
|
||||||
placeholder="Description (required)"
|
placeholder="Description (required)"
|
||||||
@@ -420,19 +423,21 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deep Link Display */}
|
{/* Deep Link Display */}
|
||||||
<div className="w-full p-4 bg-bgSubtle rounded-lg">
|
<div className="w-full p-4 bg-bgSubtle rounded-lg overflow-hidden">
|
||||||
{!requiredFieldsAreFilled() ? (
|
{!requiredFieldsAreFilled() ? (
|
||||||
<div className="text-sm text-textSubtle text-xs text-textSubtle">
|
<div className="text-sm text-textSubtle text-xs text-textSubtle">
|
||||||
Fill in required fields to generate link
|
Fill in required fields to generate link
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2 gap-4">
|
||||||
<div className="text-sm text-textSubtle text-xs text-textSubtle">
|
<div className="text-sm text-textSubtle text-xs text-textSubtle flex-shrink-0">
|
||||||
Copy this link to share with friends or paste directly in Chrome to open
|
Copy this link to share with friends or paste directly in Chrome to open
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => validateForm() && handleCopy()}
|
onClick={() => validateForm() && handleCopy()}
|
||||||
className="ml-4 p-2 hover:bg-bgApp rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="p-2 hover:bg-background-default rounded-lg transition-colors flex items-center disabled:opacity-50 disabled:hover:bg-transparent flex-shrink-0"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="w-4 h-4 text-green-500" />
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
@@ -442,16 +447,19 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
<span className="ml-1 text-sm text-textSubtle">
|
<span className="ml-1 text-sm text-textSubtle">
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{requiredFieldsAreFilled() && (
|
{requiredFieldsAreFilled() && (
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
onClick={() => validateForm() && handleCopy()}
|
onClick={() => validateForm() && handleCopy()}
|
||||||
className={`text-sm truncate dark:text-white font-mono ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
|
className={`text-sm dark:text-white font-mono cursor-pointer hover:bg-background-default p-2 rounded transition-colors overflow-x-auto whitespace-nowrap ${!title.trim() || !description.trim() ? 'text-textDisabled' : 'text-textStandard'}`}
|
||||||
|
style={{ maxWidth: '500px', width: '100%' }}
|
||||||
>
|
>
|
||||||
{deeplink}
|
{deeplink}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
@@ -465,24 +473,27 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4" />
|
||||||
{saving ? 'Saving...' : 'Save Recipe'}
|
{saving ? 'Saving...' : 'Save Recipe'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setIsScheduleModalOpen(true)}
|
onClick={() => setIsScheduleModalOpen(true)}
|
||||||
disabled={!requiredFieldsAreFilled()}
|
disabled={!requiredFieldsAreFilled()}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
Create Schedule
|
Create Schedule
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
localStorage.removeItem('recipe_editor_extensions');
|
localStorage.removeItem('recipe_editor_extensions');
|
||||||
window.close();
|
navigate(-1);
|
||||||
}}
|
}}
|
||||||
|
variant="ghost"
|
||||||
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
|
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,24 +510,16 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
onClose={() => setIsScheduleModalOpen(false)}
|
onClose={() => setIsScheduleModalOpen(false)}
|
||||||
recipe={getCurrentConfig()}
|
recipe={getCurrentConfig()}
|
||||||
onCreateSchedule={(deepLink) => {
|
onCreateSchedule={(deepLink) => {
|
||||||
// Open the schedules view with the deep link pre-filled
|
// Navigate to the schedules view with the deep link pre-filled
|
||||||
window.electron.createChatWindow(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
'schedules'
|
|
||||||
);
|
|
||||||
// Store the deep link in localStorage for the schedules view to pick up
|
|
||||||
localStorage.setItem('pendingScheduleDeepLink', deepLink);
|
localStorage.setItem('pendingScheduleDeepLink', deepLink);
|
||||||
|
navigate('/schedules');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Save Recipe Dialog */}
|
{/* Save Recipe Dialog */}
|
||||||
{showSaveDialog && (
|
{showSaveDialog && (
|
||||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50">
|
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
|
<div className="bg-background-default border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
|
||||||
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
|
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -532,7 +535,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={saveRecipeName}
|
value={saveRecipeName}
|
||||||
onChange={(e) => setSaveRecipeName(e.target.value)}
|
onChange={(e) => setSaveRecipeName(e.target.value)}
|
||||||
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
className="w-full p-3 border border-borderSubtle rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
||||||
placeholder="Enter recipe name"
|
placeholder="Enter recipe name"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
interface RecipeExpandableInfoProps {
|
interface RecipeExpandableInfoProps {
|
||||||
infoLabel: string;
|
infoLabel: string;
|
||||||
@@ -38,7 +39,7 @@ export default function RecipeExpandableInfo({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative rounded-lg bg-bgApp text-textStandard">
|
<div className="relative rounded-lg bg-background-default text-textStandard">
|
||||||
{infoValue && (
|
{infoValue && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -60,25 +61,27 @@ export default function RecipeExpandableInfo({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setValueExpanded(true);
|
setValueExpanded(true);
|
||||||
onClickEdit();
|
onClickEdit();
|
||||||
}}
|
}}
|
||||||
className="w-36 px-3 py-3 bg-bgAppInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
className="w-36 px-3 py-3 bg-background-defaultInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
||||||
>
|
>
|
||||||
{infoValue ? 'Edit' : 'Add'} {infoLabel.toLowerCase()}
|
{infoValue ? 'Edit' : 'Add'} {infoLabel.toLowerCase()}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{infoValue && isClamped && (
|
{infoValue && isClamped && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
onClick={() => setValueExpanded(!isValueExpanded)}
|
onClick={() => setValueExpanded(!isValueExpanded)}
|
||||||
aria-label={isValueExpanded ? 'Collapse content' : 'Expand content'}
|
aria-label={isValueExpanded ? 'Collapse content' : 'Expand content'}
|
||||||
title={isValueExpanded ? 'Collapse' : 'Expand'}
|
title={isValueExpanded ? 'Collapse' : 'Expand'}
|
||||||
className="bg-bgSubtle hover:bg-bgStandard p-2 rounded text-textStandard hover:text-textProminent transition-colors"
|
className="bg-background-muted hover:bg-background-default text-text-muted hover:text-text-default transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-6 h-6 transition-transform duration-300 ${
|
className={`w-6 h-6 transition-transform duration-300 ${
|
||||||
@@ -86,7 +89,7 @@ export default function RecipeExpandableInfo({
|
|||||||
}`}
|
}`}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,14 +34,14 @@ export default function RecipeInfoModal({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] z-[1000]">
|
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards] z-[1000]">
|
||||||
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col min-w-[80%] min-h-[80%] bg-bgApp rounded-xl overflow-hidden shadow-lg px-8 pt-[24px] pb-0">
|
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col min-w-[80%] min-h-[80%] bg-background-default rounded-xl overflow-hidden shadow-lg px-8 pt-[24px] pb-0">
|
||||||
<div className="flex mb-6">
|
<div className="flex mb-6">
|
||||||
<h2 className="text-xl font-semibold text-textProminent">Edit {infoLabel}</h2>
|
<h2 className="text-xl font-semibold text-textProminent">Edit {infoLabel}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-grow overflow-y-auto space-y-8">
|
<div className="flex flex-col flex-grow overflow-y-auto space-y-8">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
className="w-full flex-grow resize-none min-h-[300px] max-h-[calc(100vh-300px)] border border-borderSubtle rounded-lg p-3 text-textStandard bg-bgApp focus:outline-none focus:ring-1 focus:ring-borderProminent focus:border-borderProminent"
|
className="w-full flex-grow resize-none min-h-[300px] max-h-[calc(100vh-300px)] border border-borderSubtle rounded-lg p-3 text-textStandard bg-background-default focus:outline-none focus:ring-1 focus:ring-borderProminent focus:border-borderProminent"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder={`Enter ${infoLabel.toLowerCase()}...`}
|
placeholder={`Enter ${infoLabel.toLowerCase()}...`}
|
||||||
|
|||||||
@@ -6,37 +6,80 @@ import {
|
|||||||
saveRecipe,
|
saveRecipe,
|
||||||
generateRecipeFilename,
|
generateRecipeFilename,
|
||||||
} from '../recipe/recipeStorage';
|
} from '../recipe/recipeStorage';
|
||||||
import { FileText, Trash2, Bot, Calendar, Globe, Folder, Download } from 'lucide-react';
|
import {
|
||||||
|
FileText,
|
||||||
|
Trash2,
|
||||||
|
Bot,
|
||||||
|
Calendar,
|
||||||
|
Globe,
|
||||||
|
Folder,
|
||||||
|
AlertCircle,
|
||||||
|
Download,
|
||||||
|
} from 'lucide-react';
|
||||||
import { ScrollArea } from './ui/scroll-area';
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
import BackButton from './ui/BackButton';
|
import { Card } from './ui/card';
|
||||||
import MoreMenuLayout from './more_menu/MoreMenuLayout';
|
import { Button } from './ui/button';
|
||||||
|
import { Skeleton } from './ui/skeleton';
|
||||||
|
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||||
import { Recipe } from '../recipe';
|
import { Recipe } from '../recipe';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
import { toastSuccess, toastError } from '../toasts';
|
import { toastSuccess, toastError } from '../toasts';
|
||||||
|
|
||||||
interface RecipesViewProps {
|
interface RecipesViewProps {
|
||||||
onBack: () => void;
|
onLoadRecipe?: (recipe: Recipe) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecipesView({ onBack }: RecipesViewProps) {
|
export default function RecipesView({ onLoadRecipe }: RecipesViewProps = {}) {
|
||||||
const [savedRecipes, setSavedRecipes] = useState<SavedRecipe[]>([]);
|
const [savedRecipes, setSavedRecipes] = useState<SavedRecipe[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(null);
|
const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(null);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [showContent, setShowContent] = useState(false);
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||||
const [importDeeplink, setImportDeeplink] = useState('');
|
const [importDeeplink, setImportDeeplink] = useState('');
|
||||||
const [importRecipeName, setImportRecipeName] = useState('');
|
const [importRecipeName, setImportRecipeName] = useState('');
|
||||||
const [importGlobal, setImportGlobal] = useState(true);
|
const [importGlobal, setImportGlobal] = useState(true);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
|
// Create Recipe state
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
const [createTitle, setCreateTitle] = useState('');
|
||||||
|
const [createDescription, setCreateDescription] = useState('');
|
||||||
|
const [createInstructions, setCreateInstructions] = useState('');
|
||||||
|
const [createPrompt, setCreatePrompt] = useState('');
|
||||||
|
const [createActivities, setCreateActivities] = useState('');
|
||||||
|
const [createRecipeName, setCreateRecipeName] = useState('');
|
||||||
|
const [createGlobal, setCreateGlobal] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSavedRecipes();
|
loadSavedRecipes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Minimum loading time to prevent skeleton flash
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && showSkeleton) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowSkeleton(false);
|
||||||
|
// Add a small delay before showing content for fade-in effect
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowContent(true);
|
||||||
|
}, 50);
|
||||||
|
}, 300); // Show skeleton for at least 300ms
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return () => void 0;
|
||||||
|
}, [loading, showSkeleton]);
|
||||||
|
|
||||||
const loadSavedRecipes = async () => {
|
const loadSavedRecipes = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setShowSkeleton(true);
|
||||||
|
setShowContent(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
const recipes = await listSavedRecipes();
|
const recipes = await listSavedRecipes();
|
||||||
setSavedRecipes(recipes);
|
setSavedRecipes(recipes);
|
||||||
@@ -50,7 +93,11 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
|
|
||||||
const handleLoadRecipe = async (savedRecipe: SavedRecipe) => {
|
const handleLoadRecipe = async (savedRecipe: SavedRecipe) => {
|
||||||
try {
|
try {
|
||||||
// Use the recipe directly - no need for manual mapping
|
if (onLoadRecipe) {
|
||||||
|
// Use the callback to navigate within the same window
|
||||||
|
onLoadRecipe(savedRecipe.recipe);
|
||||||
|
} else {
|
||||||
|
// Fallback to creating a new window (for backwards compatibility)
|
||||||
window.electron.createChatWindow(
|
window.electron.createChatWindow(
|
||||||
undefined, // query
|
undefined, // query
|
||||||
undefined, // dir
|
undefined, // dir
|
||||||
@@ -59,6 +106,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
savedRecipe.recipe, // recipe config
|
savedRecipe.recipe, // recipe config
|
||||||
undefined // view type
|
undefined // view type
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load recipe:', err);
|
console.error('Failed to load recipe:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load recipe');
|
setError(err instanceof Error ? err.message : 'Failed to load recipe');
|
||||||
@@ -186,13 +234,197 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
// Create Recipe handlers
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
// Reset form with example values
|
||||||
|
setCreateTitle('Python Development Assistant');
|
||||||
|
setCreateDescription(
|
||||||
|
'A helpful assistant for Python development tasks including coding, debugging, and code review.'
|
||||||
|
);
|
||||||
|
setCreateInstructions(`You are an expert Python developer assistant. Help users with:
|
||||||
|
|
||||||
|
1. Writing clean, efficient Python code
|
||||||
|
2. Debugging and troubleshooting issues
|
||||||
|
3. Code review and optimization suggestions
|
||||||
|
4. Best practices and design patterns
|
||||||
|
5. Testing and documentation
|
||||||
|
|
||||||
|
Always provide clear explanations and working code examples.
|
||||||
|
|
||||||
|
Parameters you can use:
|
||||||
|
- {{project_type}}: The type of Python project (web, data science, CLI, etc.)
|
||||||
|
- {{python_version}}: Target Python version`);
|
||||||
|
setCreatePrompt('What Python development task can I help you with today?');
|
||||||
|
setCreateActivities('coding, debugging, testing, documentation');
|
||||||
|
setCreateRecipeName('');
|
||||||
|
setCreateGlobal(true);
|
||||||
|
setShowCreateDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateRecipe = async () => {
|
||||||
|
if (
|
||||||
|
!createTitle.trim() ||
|
||||||
|
!createDescription.trim() ||
|
||||||
|
!createInstructions.trim() ||
|
||||||
|
!createRecipeName.trim()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
// Parse activities from comma-separated string
|
||||||
|
const activities = createActivities
|
||||||
|
.split(',')
|
||||||
|
.map((activity) => activity.trim())
|
||||||
|
.filter((activity) => activity.length > 0);
|
||||||
|
|
||||||
|
// Create the recipe object
|
||||||
|
const recipe: Recipe = {
|
||||||
|
title: createTitle.trim(),
|
||||||
|
description: createDescription.trim(),
|
||||||
|
instructions: createInstructions.trim(),
|
||||||
|
prompt: createPrompt.trim() || undefined,
|
||||||
|
activities: activities.length > 0 ? activities : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveRecipe(recipe, {
|
||||||
|
name: createRecipeName.trim(),
|
||||||
|
global: createGlobal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset dialog state
|
||||||
|
setShowCreateDialog(false);
|
||||||
|
setCreateTitle('');
|
||||||
|
setCreateDescription('');
|
||||||
|
setCreateInstructions('');
|
||||||
|
setCreatePrompt('');
|
||||||
|
setCreateActivities('');
|
||||||
|
setCreateRecipeName('');
|
||||||
|
|
||||||
|
await loadSavedRecipes();
|
||||||
|
|
||||||
|
toastSuccess({
|
||||||
|
title: createRecipeName.trim(),
|
||||||
|
msg: 'Recipe created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create recipe:', error);
|
||||||
|
|
||||||
|
toastError({
|
||||||
|
title: 'Create Failed',
|
||||||
|
msg: `Failed to create recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
traceback: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-generate recipe name when title changes
|
||||||
|
const handleCreateTitleChange = (value: string) => {
|
||||||
|
setCreateTitle(value);
|
||||||
|
if (value.trim() && !createRecipeName.trim()) {
|
||||||
|
const suggestedName = value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-zA-Z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.trim();
|
||||||
|
setCreateRecipeName(suggestedName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render a recipe item
|
||||||
|
const RecipeItem = ({ savedRecipe }: { savedRecipe: SavedRecipe }) => (
|
||||||
|
<Card className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150">
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-base truncate max-w-[50vw]">{savedRecipe.recipe.title}</h3>
|
||||||
|
{savedRecipe.isGlobal ? (
|
||||||
|
<Globe className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Folder className="w-4 h-4 text-text-muted flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-text-muted text-sm mb-2 line-clamp-2">
|
||||||
|
{savedRecipe.recipe.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center text-xs text-text-muted">
|
||||||
|
<Calendar className="w-3 h-3 mr-1" />
|
||||||
|
{savedRecipe.lastModified.toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleLoadRecipe(savedRecipe);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<Bot className="w-4 h-4 mr-1" />
|
||||||
|
Use
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePreviewRecipe(savedRecipe);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 mr-1" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteRecipe(savedRecipe);
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render skeleton loader for recipe items
|
||||||
|
const RecipeSkeleton = () => (
|
||||||
|
<Card className="p-2 mb-2 bg-background-default">
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Skeleton className="h-5 w-3/4 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-full mb-2" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading || showSkeleton) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
|
<div className="space-y-6">
|
||||||
<MoreMenuLayout showMenu={false} />
|
<div className="space-y-3">
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<Skeleton className="h-6 w-24" />
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-borderProminent"></div>
|
<div className="space-y-2">
|
||||||
<p className="mt-4 text-textSubtle">Loading recipes...</p>
|
<RecipeSkeleton />
|
||||||
|
<RecipeSkeleton />
|
||||||
|
<RecipeSkeleton />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -200,128 +432,104 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
|
<div className="flex flex-col items-center justify-center h-full text-text-muted">
|
||||||
<MoreMenuLayout showMenu={false} />
|
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<p className="text-lg mb-2">Error Loading Recipes</p>
|
||||||
<p className="text-red-500 mb-4">{error}</p>
|
<p className="text-sm text-center mb-4">{error}</p>
|
||||||
<button
|
<Button onClick={loadSavedRecipes} variant="default">
|
||||||
onClick={loadSavedRecipes}
|
Try Again
|
||||||
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90"
|
</Button>
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedRecipes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center pt-2 h-full">
|
||||||
|
<p className="text-lg">No saved recipes</p>
|
||||||
|
<p className="text-sm text-text-muted">Recipe saved from chats will show up here.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
|
<div className="space-y-2">
|
||||||
<MoreMenuLayout showMenu={false} />
|
{savedRecipes.map((savedRecipe) => (
|
||||||
|
<RecipeItem
|
||||||
|
key={`${savedRecipe.isGlobal ? 'global' : 'local'}-${savedRecipe.name}`}
|
||||||
|
savedRecipe={savedRecipe}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<ScrollArea className="h-full w-full">
|
return (
|
||||||
<div className="flex flex-col pb-24">
|
<>
|
||||||
<div className="px-8 pt-6 pb-4">
|
<MainPanelLayout>
|
||||||
<BackButton onClick={onBack} />
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||||
<h1 className="text-3xl font-medium text-textStandard">Saved Recipes</h1>
|
<div className="flex flex-col page-transition">
|
||||||
<button
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<h1 className="text-4xl font-light">Recipes</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateClick}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
Create Recipe
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
onClick={handleImportClick}
|
onClick={handleImportClick}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors text-sm font-medium"
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
Import Recipe
|
Import Recipe
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-muted mb-1">
|
||||||
|
View and manage your saved recipes to quickly start new sessions with predefined
|
||||||
|
configurations.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Area */}
|
<div className="flex-1 min-h-0 relative px-8">
|
||||||
<div className="flex-1 pt-[20px]">
|
<ScrollArea className="h-full">
|
||||||
{savedRecipes.length === 0 ? (
|
<div
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center px-8">
|
className={`h-full relative transition-all duration-300 ${
|
||||||
<FileText className="w-16 h-16 text-textSubtle mb-4" />
|
showContent ? 'opacity-100 animate-in fade-in ' : 'opacity-0'
|
||||||
<h3 className="text-lg font-medium text-textStandard mb-2">No saved recipes</h3>
|
}`}
|
||||||
<p className="text-textSubtle">
|
|
||||||
Create and save recipes from the Recipe Editor to see them here.
|
|
||||||
</p>
|
|
||||||
<p className="text-textSubtle text-sm mt-2">
|
|
||||||
You can also save recipes from active recipe sessions using the Settings menu.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-8 px-8">
|
|
||||||
{savedRecipes.map((savedRecipe) => (
|
|
||||||
<section
|
|
||||||
key={`${savedRecipe.isGlobal ? 'global' : 'local'}-${savedRecipe.name}`}
|
|
||||||
className="border-b border-borderSubtle pb-8"
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-4">
|
{renderContent()}
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h3 className="text-xl font-medium text-textStandard">
|
|
||||||
{savedRecipe.recipe.title}
|
|
||||||
</h3>
|
|
||||||
{savedRecipe.isGlobal ? (
|
|
||||||
<Globe className="w-4 h-4 text-textSubtle" />
|
|
||||||
) : (
|
|
||||||
<Folder className="w-4 h-4 text-textSubtle" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-textSubtle mb-2">{savedRecipe.recipe.description}</p>
|
|
||||||
<div className="flex items-center text-xs text-textSubtle">
|
|
||||||
<Calendar className="w-3 h-3 mr-1" />
|
|
||||||
{savedRecipe.lastModified.toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleLoadRecipe(savedRecipe)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg hover:bg-opacity-90 transition-colors text-sm font-medium"
|
|
||||||
>
|
|
||||||
<Bot className="w-4 h-4" />
|
|
||||||
Use Recipe
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handlePreviewRecipe(savedRecipe)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4" />
|
|
||||||
Preview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteRecipe(savedRecipe)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainPanelLayout>
|
||||||
|
|
||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
{showPreview && selectedRecipe && (
|
{showPreview && selectedRecipe && (
|
||||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50">
|
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto">
|
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[600px] max-w-[90vw] max-h-[80vh] overflow-y-auto">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-medium text-textStandard">
|
<h3 className="text-xl font-medium text-text-standard">
|
||||||
{selectedRecipe.recipe.title}
|
{selectedRecipe.recipe.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-textSubtle">
|
<p className="text-sm text-text-muted">
|
||||||
{selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'}
|
{selectedRecipe.isGlobal ? 'Global recipe' : 'Project recipe'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPreview(false)}
|
onClick={() => setShowPreview(false)}
|
||||||
className="text-textSubtle hover:text-textStandard text-2xl leading-none"
|
className="text-text-muted hover:text-text-standard text-2xl leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -329,15 +537,15 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-textStandard mb-2">Description</h4>
|
<h4 className="text-sm font-medium text-text-standard mb-2">Description</h4>
|
||||||
<p className="text-textSubtle">{selectedRecipe.recipe.description}</p>
|
<p className="text-text-muted">{selectedRecipe.recipe.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedRecipe.recipe.instructions && (
|
{selectedRecipe.recipe.instructions && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-textStandard mb-2">Instructions</h4>
|
<h4 className="text-sm font-medium text-text-standard mb-2">Instructions</h4>
|
||||||
<div className="bg-bgSubtle border border-borderSubtle p-3 rounded-lg">
|
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
|
||||||
<pre className="text-sm text-textSubtle whitespace-pre-wrap font-mono">
|
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
|
||||||
{selectedRecipe.recipe.instructions}
|
{selectedRecipe.recipe.instructions}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,9 +554,9 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
|
|
||||||
{selectedRecipe.recipe.prompt && (
|
{selectedRecipe.recipe.prompt && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-textStandard mb-2">Initial Prompt</h4>
|
<h4 className="text-sm font-medium text-text-standard mb-2">Initial Prompt</h4>
|
||||||
<div className="bg-bgSubtle border border-borderSubtle p-3 rounded-lg">
|
<div className="bg-background-muted border border-border-subtle p-3 rounded-lg">
|
||||||
<pre className="text-sm text-textSubtle whitespace-pre-wrap font-mono">
|
<pre className="text-sm text-text-muted whitespace-pre-wrap font-mono">
|
||||||
{selectedRecipe.recipe.prompt}
|
{selectedRecipe.recipe.prompt}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,12 +565,12 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
|
|
||||||
{selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && (
|
{selectedRecipe.recipe.activities && selectedRecipe.recipe.activities.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-textStandard mb-2">Activities</h4>
|
<h4 className="text-sm font-medium text-text-standard mb-2">Activities</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedRecipe.recipe.activities.map((activity, index) => (
|
{selectedRecipe.recipe.activities.map((activity, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="px-2 py-1 bg-bgSubtle border border-borderSubtle text-textSubtle rounded text-sm"
|
className="px-2 py-1 bg-background-muted border border-border-subtle text-text-muted rounded text-sm"
|
||||||
>
|
>
|
||||||
{activity}
|
{activity}
|
||||||
</span>
|
</span>
|
||||||
@@ -372,22 +580,19 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-borderSubtle">
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-border-subtle">
|
||||||
<button
|
<Button onClick={() => setShowPreview(false)} variant="ghost">
|
||||||
onClick={() => setShowPreview(false)}
|
|
||||||
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
Close
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowPreview(false);
|
setShowPreview(false);
|
||||||
handleLoadRecipe(selectedRecipe);
|
handleLoadRecipe(selectedRecipe);
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg hover:bg-opacity-90 transition-colors font-medium"
|
variant="default"
|
||||||
>
|
>
|
||||||
Load Recipe
|
Load Recipe
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,15 +600,15 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
|
|
||||||
{/* Import Recipe Dialog */}
|
{/* Import Recipe Dialog */}
|
||||||
{showImportDialog && (
|
{showImportDialog && (
|
||||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50">
|
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-[500px] max-w-[90vw]">
|
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
|
||||||
<h3 className="text-lg font-medium text-textProminent mb-4">Import Recipe</h3>
|
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="import-deeplink"
|
htmlFor="import-deeplink"
|
||||||
className="block text-sm font-medium text-textStandard mb-2"
|
className="block text-sm font-medium text-text-standard mb-2"
|
||||||
>
|
>
|
||||||
Recipe Deeplink
|
Recipe Deeplink
|
||||||
</label>
|
</label>
|
||||||
@@ -411,12 +616,12 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
id="import-deeplink"
|
id="import-deeplink"
|
||||||
value={importDeeplink}
|
value={importDeeplink}
|
||||||
onChange={(e) => handleDeeplinkChange(e.target.value)}
|
onChange={(e) => handleDeeplinkChange(e.target.value)}
|
||||||
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent resize-none"
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
placeholder="Paste your goose://recipe?config=... deeplink here"
|
placeholder="Paste your goose://recipe?config=... deeplink here"
|
||||||
rows={3}
|
rows={3}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-textSubtle mt-1">
|
<p className="text-xs text-text-muted mt-1">
|
||||||
Paste a recipe deeplink starting with "goose://recipe?config="
|
Paste a recipe deeplink starting with "goose://recipe?config="
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,7 +629,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="import-recipe-name"
|
htmlFor="import-recipe-name"
|
||||||
className="block text-sm font-medium text-textStandard mb-2"
|
className="block text-sm font-medium text-text-standard mb-2"
|
||||||
>
|
>
|
||||||
Recipe Name
|
Recipe Name
|
||||||
</label>
|
</label>
|
||||||
@@ -433,13 +638,13 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={importRecipeName}
|
value={importRecipeName}
|
||||||
onChange={(e) => setImportRecipeName(e.target.value)}
|
onChange={(e) => setImportRecipeName(e.target.value)}
|
||||||
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Enter a name for the imported recipe"
|
placeholder="Enter a name for the imported recipe"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||||
Save Location
|
Save Location
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -451,7 +656,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
onChange={() => setImportGlobal(true)}
|
onChange={() => setImportGlobal(true)}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-textStandard">
|
<span className="text-sm text-text-standard">
|
||||||
Global - Available across all Goose sessions
|
Global - Available across all Goose sessions
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -463,7 +668,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
onChange={() => setImportGlobal(false)}
|
onChange={() => setImportGlobal(false)}
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-textStandard">
|
<span className="text-sm text-text-standard">
|
||||||
Directory - Available in the working directory
|
Directory - Available in the working directory
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -472,28 +677,211 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 mt-6">
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowImportDialog(false);
|
setShowImportDialog(false);
|
||||||
setImportDeeplink('');
|
setImportDeeplink('');
|
||||||
setImportRecipeName('');
|
setImportRecipeName('');
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
|
variant="ghost"
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={handleImportRecipe}
|
onClick={handleImportRecipe}
|
||||||
disabled={!importDeeplink.trim() || !importRecipeName.trim() || importing}
|
disabled={!importDeeplink.trim() || !importRecipeName.trim() || importing}
|
||||||
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="default"
|
||||||
>
|
>
|
||||||
{importing ? 'Importing...' : 'Import Recipe'}
|
{importing ? 'Importing...' : 'Import Recipe'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Recipe Dialog */}
|
||||||
|
{showCreateDialog && (
|
||||||
|
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[700px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 className="text-lg font-medium text-text-standard mb-4">Create New Recipe</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="create-title"
|
||||||
|
className="block text-sm font-medium text-text-standard mb-2"
|
||||||
|
>
|
||||||
|
Title <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="create-title"
|
||||||
|
type="text"
|
||||||
|
value={createTitle}
|
||||||
|
onChange={(e) => handleCreateTitleChange(e.target.value)}
|
||||||
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Recipe title"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="create-description"
|
||||||
|
className="block text-sm font-medium text-text-standard mb-2"
|
||||||
|
>
|
||||||
|
Description <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="create-description"
|
||||||
|
type="text"
|
||||||
|
value={createDescription}
|
||||||
|
onChange={(e) => setCreateDescription(e.target.value)}
|
||||||
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Brief description of what this recipe does"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="create-instructions"
|
||||||
|
className="block text-sm font-medium text-text-standard mb-2"
|
||||||
|
>
|
||||||
|
Instructions <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="create-instructions"
|
||||||
|
value={createInstructions}
|
||||||
|
onChange={(e) => setCreateInstructions(e.target.value)}
|
||||||
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm"
|
||||||
|
placeholder="Detailed instructions for the AI agent..."
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
Use {`{{parameter_name}}`} to define parameters that users can fill in
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="create-prompt"
|
||||||
|
className="block text-sm font-medium text-text-standard mb-2"
|
||||||
|
>
|
||||||
|
Initial Prompt (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="create-prompt"
|
||||||
|
value={createPrompt}
|
||||||
|
onChange={(e) => setCreatePrompt(e.target.value)}
|
||||||
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
placeholder="First message to send when the recipe starts..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="create-activities"
|
||||||
|
className="block text-sm font-medium text-text-standard mb-2"
|
||||||
|
>
|
||||||
|
Activities (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="create-activities"
|
||||||
|
type="text"
|
||||||
|
value={createActivities}
|
||||||
|
onChange={(e) => setCreateActivities(e.target.value)}
|
||||||
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="coding, debugging, testing, documentation (comma-separated)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
Comma-separated list of activities this recipe helps with
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="create-recipe-name"
|
||||||
|
className="block text-sm font-medium text-text-standard mb-2"
|
||||||
|
>
|
||||||
|
Recipe Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="create-recipe-name"
|
||||||
|
type="text"
|
||||||
|
value={createRecipeName}
|
||||||
|
onChange={(e) => setCreateRecipeName(e.target.value)}
|
||||||
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="File name for the recipe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-standard mb-2">
|
||||||
|
Save Location
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="create-save-location"
|
||||||
|
checked={createGlobal}
|
||||||
|
onChange={() => setCreateGlobal(true)}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-standard">
|
||||||
|
Global - Available across all Goose sessions
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="create-save-location"
|
||||||
|
checked={!createGlobal}
|
||||||
|
onChange={() => setCreateGlobal(false)}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-standard">
|
||||||
|
Directory - Available in the working directory
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateDialog(false);
|
||||||
|
setCreateTitle('');
|
||||||
|
setCreateDescription('');
|
||||||
|
setCreateInstructions('');
|
||||||
|
setCreatePrompt('');
|
||||||
|
setCreateActivities('');
|
||||||
|
setCreateRecipeName('');
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateRecipe}
|
||||||
|
disabled={
|
||||||
|
!createTitle.trim() ||
|
||||||
|
!createDescription.trim() ||
|
||||||
|
!createInstructions.trim() ||
|
||||||
|
!createRecipeName.trim() ||
|
||||||
|
creating
|
||||||
|
}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : 'Create Recipe'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import SplashPills from './SplashPills';
|
import { Card } from './ui/card';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import { Greeting } from './common/Greeting';
|
||||||
import GooseLogo from './GooseLogo';
|
import GooseLogo from './GooseLogo';
|
||||||
|
|
||||||
|
// Register GSAP plugins
|
||||||
|
gsap.registerPlugin();
|
||||||
|
|
||||||
interface SplashProps {
|
interface SplashProps {
|
||||||
append: (text: string) => void;
|
append: (text: string) => void;
|
||||||
activities: string[] | null;
|
activities: string[] | null;
|
||||||
@@ -8,31 +13,65 @@ interface SplashProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Splash({ append, activities, title }: SplashProps) {
|
export default function Splash({ append, activities, title }: SplashProps) {
|
||||||
|
const pills = activities || [];
|
||||||
|
|
||||||
|
// Find any pill that starts with "message:"
|
||||||
|
const messagePillIndex = pills.findIndex((pill) => pill.toLowerCase().startsWith('message:'));
|
||||||
|
|
||||||
|
// Extract the message pill and the remaining pills
|
||||||
|
const messagePill = messagePillIndex >= 0 ? pills[messagePillIndex] : null;
|
||||||
|
const remainingPills =
|
||||||
|
messagePillIndex >= 0
|
||||||
|
? [...pills.slice(0, messagePillIndex), ...pills.slice(messagePillIndex + 1)]
|
||||||
|
: pills;
|
||||||
|
|
||||||
|
// If we have activities (recipe mode), show a simplified version without greeting
|
||||||
|
if (activities && activities.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col px-6">
|
||||||
|
{/* Animated goose icon */}
|
||||||
|
<div className="flex justify-start mb-6">
|
||||||
|
<GooseLogo size="default" hover={true} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{messagePill && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg border animate-[fadein_500ms_ease-in_forwards]">
|
||||||
|
{messagePill.replace(/^message:/i, '').trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 animate-[fadein_500ms_ease-in_forwards]">
|
||||||
|
{remainingPills.map((content, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
onClick={() => append(content)}
|
||||||
|
title={content.length > 60 ? content : undefined}
|
||||||
|
className="cursor-pointer px-3 py-1.5 text-sm hover:bg-bgSubtle transition-colors"
|
||||||
|
>
|
||||||
|
{content.length > 60 ? content.slice(0, 60) + '...' : content}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default splash screen (no recipe) - show greeting and title if provided
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
{title && (
|
{title && (
|
||||||
<div className="flex items-center px-4 py-2">
|
<div className="flex items-center px-4 py-2 mb-4">
|
||||||
<span className="w-2 h-2 rounded-full bg-blockTeal mr-2" />
|
<span className="w-2 h-2 rounded-full bg-blockTeal mr-2" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
<span className="text-textSubtle">Agent</span>{' '}
|
<span className="text-text-muted">Agent</span>{' '}
|
||||||
<span className="text-textStandard">{title}</span>
|
<span className="text-text-default">{title}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<div className="h-full flex flex-col pb-12">
|
|
||||||
<div className="p-8">
|
|
||||||
<div className="relative text-textStandard mb-12">
|
|
||||||
<div className="w-min animate-[flyin_2s_var(--spring-easing)_forwards]">
|
|
||||||
<GooseLogo />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{/* Compact greeting section */}
|
||||||
<SplashPills append={append} activities={activities} />
|
<div className="flex flex-col px-6 mb-0">
|
||||||
</div>
|
<Greeting className="text-text-prominent text-4xl font-light mb-2" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
|
|||||||
|
|
||||||
if (!needsExpansion) {
|
if (!needsExpansion) {
|
||||||
return (
|
return (
|
||||||
<div className="text-sm mb-2">
|
<div className="text-xs mb-2">
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<span className="text-textSubtle min-w-[140px]">{key}</span>
|
<span className="text-textSubtle min-w-[140px]">{key}</span>
|
||||||
<span className="text-textPlaceholder">{value}</span>
|
<span className="text-textPlaceholder">{value}</span>
|
||||||
@@ -78,9 +78,11 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row text-xs">
|
||||||
<span className="mr- text-textPlaceholder min-w-[140px]2">{key}:</span>
|
<span className="text-textSubtle min-w-[140px]">{key}</span>
|
||||||
<pre className="whitespace-pre-wrap text-textPlaceholder">{content}</pre>
|
<pre className="whitespace-pre-wrap text-textPlaceholder overflow-x-auto max-w-full">
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { snakeToTitleCase } from '../utils';
|
|||||||
import PermissionModal from './settings/permission/PermissionModal';
|
import PermissionModal from './settings/permission/PermissionModal';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { confirmPermission } from '../api';
|
import { confirmPermission } from '../api';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
const ALWAYS_ALLOW = 'always_allow';
|
const ALWAYS_ALLOW = 'always_allow';
|
||||||
const ALLOW_ONCE = 'allow_once';
|
const ALLOW_ONCE = 'allow_once';
|
||||||
@@ -58,16 +59,16 @@ export default function ToolConfirmation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return isCancelledMessage ? (
|
return isCancelledMessage ? (
|
||||||
<div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 text-textStandard">
|
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
|
||||||
Tool call confirmation is cancelled.
|
Tool call confirmation is cancelled.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 rounded-b-none text-textStandard">
|
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 rounded-b-none text-textStandard">
|
||||||
Goose would like to call the above tool. Allow?
|
Goose would like to call the above tool. Allow?
|
||||||
</div>
|
</div>
|
||||||
{clicked ? (
|
{clicked ? (
|
||||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex items-center justify-between">
|
<div className="goose-message-tool bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{status === 'always_allow' && (
|
{status === 'always_allow' && (
|
||||||
<svg
|
<svg
|
||||||
@@ -118,31 +119,24 @@ export default function ToolConfirmation({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center">
|
<div className="goose-message-tool bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-2 pb-2 flex gap-2 items-center">
|
||||||
<button
|
<Button className="rounded-full" onClick={() => handleButtonClick(ALWAYS_ALLOW)}>
|
||||||
className={
|
|
||||||
'bg-black text-white dark:bg-white dark:text-black rounded-full px-6 py-2 transition'
|
|
||||||
}
|
|
||||||
onClick={() => handleButtonClick(ALWAYS_ALLOW)}
|
|
||||||
>
|
|
||||||
Always Allow
|
Always Allow
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className={
|
className="rounded-full"
|
||||||
'bg-bgProminent text-white dark:text-white rounded-full px-6 py-2 transition'
|
variant="secondary"
|
||||||
}
|
|
||||||
onClick={() => handleButtonClick(ALLOW_ONCE)}
|
onClick={() => handleButtonClick(ALLOW_ONCE)}
|
||||||
>
|
>
|
||||||
Allow Once
|
Allow Once
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className={
|
className="rounded-full"
|
||||||
'bg-white text-black dark:bg-black dark:text-white border border-gray-300 dark:border-gray-700 rounded-full px-6 py-2 transition'
|
variant="outline"
|
||||||
}
|
|
||||||
onClick={() => handleButtonClick(DENY)}
|
onClick={() => handleButtonClick(DENY)}
|
||||||
>
|
>
|
||||||
Deny
|
Deny
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Card } from './ui/card';
|
import { Button } from './ui/button';
|
||||||
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
|
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
|
||||||
import MarkdownContent from './MarkdownContent';
|
import MarkdownContent from './MarkdownContent';
|
||||||
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
|
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
|
||||||
import { snakeToTitleCase } from '../utils';
|
import { cn, snakeToTitleCase } from '../utils';
|
||||||
import Dot, { LoadingStatus } from './ui/Dot';
|
import Dot, { LoadingStatus } from './ui/Dot';
|
||||||
import Expand from './ui/Expand';
|
|
||||||
import { NotificationEvent } from '../hooks/useMessageStream';
|
import { NotificationEvent } from '../hooks/useMessageStream';
|
||||||
|
import { ChevronRight, LoaderCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface ToolCallWithResponseProps {
|
interface ToolCallWithResponseProps {
|
||||||
isCancelledMessage: boolean;
|
isCancelledMessage: boolean;
|
||||||
toolRequest: ToolRequestMessageContent;
|
toolRequest: ToolRequestMessageContent;
|
||||||
toolResponse?: ToolResponseMessageContent;
|
toolResponse?: ToolResponseMessageContent;
|
||||||
notifications?: NotificationEvent[];
|
notifications?: NotificationEvent[];
|
||||||
|
isStreamingMessage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ToolCallWithResponse({
|
export default function ToolCallWithResponse({
|
||||||
@@ -20,6 +21,7 @@ export default function ToolCallWithResponse({
|
|||||||
toolRequest,
|
toolRequest,
|
||||||
toolResponse,
|
toolResponse,
|
||||||
notifications,
|
notifications,
|
||||||
|
isStreamingMessage = false,
|
||||||
}: ToolCallWithResponseProps) {
|
}: ToolCallWithResponseProps) {
|
||||||
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
|
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
|
||||||
if (!toolCall) {
|
if (!toolCall) {
|
||||||
@@ -27,10 +29,14 @@ export default function ToolCallWithResponse({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'w-full text-textSubtle text-sm'}>
|
<div
|
||||||
<Card className="">
|
className={cn(
|
||||||
<ToolCallView {...{ isCancelledMessage, toolCall, toolResponse, notifications }} />
|
'w-full text-sm rounded-lg overflow-hidden border-borderSubtle border bg-background-muted'
|
||||||
</Card>
|
)}
|
||||||
|
>
|
||||||
|
<ToolCallView
|
||||||
|
{...{ isCancelledMessage, toolCall, toolResponse, notifications, isStreamingMessage }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,10 +65,19 @@ function ToolCallExpandable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<button onClick={toggleExpand} className="w-full flex justify-between items-center pr-2">
|
<Button
|
||||||
<span className="flex items-center">{label}</span>
|
onClick={toggleExpand}
|
||||||
<Expand size={5} isExpanded={isExpanded} />
|
className="group w-full flex justify-between items-center pr-2 transition-colors rounded-none"
|
||||||
</button>
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<span className="flex items-center font-mono">{label}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'group-hover:opacity-100 transition-transform opacity-70',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
{isExpanded && <div>{children}</div>}
|
{isExpanded && <div>{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -76,6 +91,7 @@ interface ToolCallViewProps {
|
|||||||
};
|
};
|
||||||
toolResponse?: ToolResponseMessageContent;
|
toolResponse?: ToolResponseMessageContent;
|
||||||
notifications?: NotificationEvent[];
|
notifications?: NotificationEvent[];
|
||||||
|
isStreamingMessage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Progress {
|
interface Progress {
|
||||||
@@ -110,8 +126,28 @@ function ToolCallView({
|
|||||||
toolCall,
|
toolCall,
|
||||||
toolResponse,
|
toolResponse,
|
||||||
notifications,
|
notifications,
|
||||||
|
isStreamingMessage = false,
|
||||||
}: ToolCallViewProps) {
|
}: ToolCallViewProps) {
|
||||||
const responseStyle = localStorage.getItem('response_style');
|
const [responseStyle, setResponseStyle] = useState(() => localStorage.getItem('response_style'));
|
||||||
|
|
||||||
|
// Listen for localStorage changes to update the response style
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorageChange = () => {
|
||||||
|
setResponseStyle(localStorage.getItem('response_style'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for storage events (changes from other tabs/windows)
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
|
||||||
|
// Listen for custom events (changes from same tab)
|
||||||
|
window.addEventListener('responseStyleChanged', handleStorageChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handleStorageChange);
|
||||||
|
window.removeEventListener('responseStyleChanged', handleStorageChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isExpandToolDetails = (() => {
|
const isExpandToolDetails = (() => {
|
||||||
switch (responseStyle) {
|
switch (responseStyle) {
|
||||||
case 'concise':
|
case 'concise':
|
||||||
@@ -123,9 +159,27 @@ function ToolCallView({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const isToolDetails = Object.entries(toolCall?.arguments).length > 0;
|
const isToolDetails = Object.entries(toolCall?.arguments).length > 0;
|
||||||
const loadingStatus: LoadingStatus = !toolResponse?.toolResult.status
|
|
||||||
? 'loading'
|
// Check if streaming has finished but no tool response was received
|
||||||
: toolResponse?.toolResult.status;
|
// This is a workaround for cases where the backend doesn't send tool responses
|
||||||
|
const isStreamingComplete = !isStreamingMessage;
|
||||||
|
const shouldShowAsComplete = isStreamingComplete && !toolResponse;
|
||||||
|
|
||||||
|
const loadingStatus: LoadingStatus = !toolResponse
|
||||||
|
? shouldShowAsComplete
|
||||||
|
? 'success'
|
||||||
|
: 'loading'
|
||||||
|
: toolResponse.toolResult.status;
|
||||||
|
|
||||||
|
// Tool call timing tracking
|
||||||
|
const [startTime, setStartTime] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Track when tool call starts (when there's no response yet)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toolResponse && startTime === null) {
|
||||||
|
setStartTime(Date.now());
|
||||||
|
}
|
||||||
|
}, [toolResponse, startTime]);
|
||||||
|
|
||||||
const toolResults: { result: Content; isExpandToolResults: boolean }[] =
|
const toolResults: { result: Content; isExpandToolResults: boolean }[] =
|
||||||
loadingStatus === 'success' && Array.isArray(toolResponse?.toolResult.value)
|
loadingStatus === 'success' && Array.isArray(toolResponse?.toolResult.value)
|
||||||
@@ -134,10 +188,17 @@ function ToolCallView({
|
|||||||
const audience = item.annotations?.audience as string[] | undefined;
|
const audience = item.annotations?.audience as string[] | undefined;
|
||||||
return !audience || audience.includes('user');
|
return !audience || audience.includes('user');
|
||||||
})
|
})
|
||||||
.map((item) => ({
|
.map((item) => {
|
||||||
|
// Use user preference for detailed/concise, but still respect high priority items
|
||||||
|
const priority = (item.annotations?.priority as number | undefined) ?? -1;
|
||||||
|
const isHighPriority = priority >= 0.5;
|
||||||
|
const shouldExpandBasedOnStyle = responseStyle === 'detailed' || responseStyle === null;
|
||||||
|
|
||||||
|
return {
|
||||||
result: item,
|
result: item,
|
||||||
isExpandToolResults: ((item.annotations?.priority as number | undefined) ?? -1) >= 0.5,
|
isExpandToolResults: isHighPriority || shouldExpandBasedOnStyle,
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const logs = notifications
|
const logs = notifications
|
||||||
@@ -163,11 +224,19 @@ function ToolCallView({
|
|||||||
const isRenderingProgress =
|
const isRenderingProgress =
|
||||||
loadingStatus === 'loading' && (progressEntries.length > 0 || (logs || []).length > 0);
|
loadingStatus === 'loading' && (progressEntries.length > 0 || (logs || []).length > 0);
|
||||||
|
|
||||||
// Only expand if there are actual results that need to be shown, not just for tool details
|
// Determine if the main tool call should be expanded
|
||||||
const isShouldExpand = toolResults.some((v) => v.isExpandToolResults);
|
const isShouldExpand = (() => {
|
||||||
|
// Always expand if there are high priority results that need to be shown
|
||||||
|
const hasHighPriorityResults = toolResults.some((v) => v.isExpandToolResults);
|
||||||
|
|
||||||
|
// Also expand based on user preference for detailed mode
|
||||||
|
const shouldExpandBasedOnStyle = responseStyle === 'detailed' || responseStyle === null;
|
||||||
|
|
||||||
|
return hasHighPriorityResults || shouldExpandBasedOnStyle;
|
||||||
|
})();
|
||||||
|
|
||||||
// Function to create a descriptive representation of what the tool is doing
|
// Function to create a descriptive representation of what the tool is doing
|
||||||
const getToolDescription = () => {
|
const getToolDescription = (): string | null => {
|
||||||
const args = toolCall.arguments as Record<string, ToolCallArgumentValue>;
|
const args = toolCall.arguments as Record<string, ToolCallArgumentValue>;
|
||||||
const toolName = toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2);
|
const toolName = toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2);
|
||||||
|
|
||||||
@@ -318,8 +387,14 @@ function ToolCallView({
|
|||||||
isForceExpand={isShouldExpand}
|
isForceExpand={isShouldExpand}
|
||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
|
<div className="w-2 flex items-center justify-center">
|
||||||
|
{loadingStatus === 'loading' ? (
|
||||||
|
<LoaderCircle className="animate-spin text-text-muted" size={3} />
|
||||||
|
) : (
|
||||||
<Dot size={2} loadingStatus={loadingStatus} />
|
<Dot size={2} loadingStatus={loadingStatus} />
|
||||||
<span className="ml-[10px]">
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="ml-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const description = getToolDescription();
|
const description = getToolDescription();
|
||||||
if (description) {
|
if (description) {
|
||||||
@@ -334,17 +409,19 @@ function ToolCallView({
|
|||||||
>
|
>
|
||||||
{/* Tool Details */}
|
{/* Tool Details */}
|
||||||
{isToolDetails && (
|
{isToolDetails && (
|
||||||
<div className="bg-bgStandard rounded-t mt-1">
|
<div className="border-t border-borderSubtle">
|
||||||
<ToolDetailsView toolCall={toolCall} isStartExpanded={isExpandToolDetails} />
|
<ToolDetailsView toolCall={toolCall} isStartExpanded={isExpandToolDetails} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{logs && logs.length > 0 && (
|
{logs && logs.length > 0 && (
|
||||||
<div className="bg-bgStandard mt-1">
|
<div className="border-t border-borderSubtle">
|
||||||
<ToolLogsView
|
<ToolLogsView
|
||||||
logs={logs}
|
logs={logs}
|
||||||
working={toolResults.length === 0}
|
working={loadingStatus === 'loading'}
|
||||||
isStartExpanded={toolResults.length === 0}
|
isStartExpanded={
|
||||||
|
loadingStatus === 'loading' || responseStyle === 'detailed' || responseStyle === null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -352,7 +429,7 @@ function ToolCallView({
|
|||||||
{toolResults.length === 0 &&
|
{toolResults.length === 0 &&
|
||||||
progressEntries.length > 0 &&
|
progressEntries.length > 0 &&
|
||||||
progressEntries.map((entry, index) => (
|
progressEntries.map((entry, index) => (
|
||||||
<div className="p-2" key={index}>
|
<div className="p-3 border-t border-borderSubtle" key={index}>
|
||||||
<ProgressBar progress={entry.progress} total={entry.total} message={entry.message} />
|
<ProgressBar progress={entry.progress} total={entry.total} message={entry.message} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -361,15 +438,8 @@ function ToolCallView({
|
|||||||
{!isCancelledMessage && (
|
{!isCancelledMessage && (
|
||||||
<>
|
<>
|
||||||
{toolResults.map(({ result, isExpandToolResults }, index) => {
|
{toolResults.map(({ result, isExpandToolResults }, index) => {
|
||||||
const isLast = index === toolResults.length - 1;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={index} className={cn('border-t border-borderSubtle')}>
|
||||||
key={index}
|
|
||||||
className={`bg-bgStandard mt-1
|
|
||||||
${isToolDetails || index > 0 ? '' : 'rounded-t'}
|
|
||||||
${isLast ? 'rounded-b' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<ToolResultView result={result} isStartExpanded={isExpandToolResults} />
|
<ToolResultView result={result} isStartExpanded={isExpandToolResults} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -391,13 +461,14 @@ interface ToolDetailsViewProps {
|
|||||||
function ToolDetailsView({ toolCall, isStartExpanded }: ToolDetailsViewProps) {
|
function ToolDetailsView({ toolCall, isStartExpanded }: ToolDetailsViewProps) {
|
||||||
return (
|
return (
|
||||||
<ToolCallExpandable
|
<ToolCallExpandable
|
||||||
label="Tool Details"
|
label={<span className="pl-4 font-medium">Tool Details</span>}
|
||||||
className="pl-[19px] py-1"
|
|
||||||
isStartExpanded={isStartExpanded}
|
isStartExpanded={isStartExpanded}
|
||||||
>
|
>
|
||||||
|
<div className="pr-4 pl-8">
|
||||||
{toolCall.arguments && (
|
{toolCall.arguments && (
|
||||||
<ToolCallArguments args={toolCall.arguments as Record<string, ToolCallArgumentValue>} />
|
<ToolCallArguments args={toolCall.arguments as Record<string, ToolCallArgumentValue>} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</ToolCallExpandable>
|
</ToolCallExpandable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -410,14 +481,14 @@ interface ToolResultViewProps {
|
|||||||
function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
|
function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
|
||||||
return (
|
return (
|
||||||
<ToolCallExpandable
|
<ToolCallExpandable
|
||||||
label={<span className="pl-[19px] py-1">Output</span>}
|
label={<span className="pl-4 py-1 font-medium">Output</span>}
|
||||||
isStartExpanded={isStartExpanded}
|
isStartExpanded={isStartExpanded}
|
||||||
>
|
>
|
||||||
<div className="bg-bgApp rounded-b pl-[19px] pr-2 py-4">
|
<div className="pl-4 pr-4 py-4">
|
||||||
{result.type === 'text' && result.text && (
|
{result.type === 'text' && result.text && (
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={result.text}
|
content={result.text}
|
||||||
className="whitespace-pre-wrap p-2 max-w-full overflow-x-auto"
|
className="whitespace-pre-wrap max-w-full overflow-x-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{result.type === 'image' && (
|
{result.type === 'image' && (
|
||||||
@@ -457,7 +528,7 @@ function ToolLogsView({
|
|||||||
return (
|
return (
|
||||||
<ToolCallExpandable
|
<ToolCallExpandable
|
||||||
label={
|
label={
|
||||||
<span className="pl-[19px] py-1">
|
<span className="pl-4 py-1 font-medium flex items-center">
|
||||||
<span>Logs</span>
|
<span>Logs</span>
|
||||||
{working && (
|
{working && (
|
||||||
<div className="mx-2 inline-block">
|
<div className="mx-2 inline-block">
|
||||||
@@ -475,7 +546,7 @@ function ToolLogsView({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
className={`flex flex-col items-start space-y-2 overflow-y-auto ${working ? 'max-h-[4rem]' : 'max-h-[20rem]'} bg-bgApp`}
|
className={`flex flex-col items-start space-y-2 overflow-y-auto p-4 ${working ? 'max-h-[4rem]' : 'max-h-[20rem]'}`}
|
||||||
>
|
>
|
||||||
{logs.map((log, i) => (
|
{logs.map((log, i) => (
|
||||||
<span key={i} className="font-mono text-sm text-textSubtle">
|
<span key={i} className="font-mono text-sm text-textSubtle">
|
||||||
@@ -493,16 +564,16 @@ const ProgressBar = ({ progress, total, message }: Omit<Progress, 'progressToken
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
{message && <div className="text-sm text-gray-700">{message}</div>}
|
{message && <div className="text-sm text-textSubtle">{message}</div>}
|
||||||
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden relative">
|
<div className="w-full bg-background-subtle rounded-full h-4 overflow-hidden relative">
|
||||||
{isDeterminate ? (
|
{isDeterminate ? (
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 h-full transition-all duration-300"
|
className="bg-primary h-full transition-all duration-300"
|
||||||
style={{ width: `${percent}%` }}
|
style={{ width: `${percent}%` }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 animate-indeterminate bg-blue-500" />
|
<div className="absolute inset-0 animate-indeterminate bg-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ export default function UserMessage({ message }: UserMessageProps) {
|
|||||||
const urls = extractUrls(displayText, []);
|
const urls = extractUrls(displayText, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end mt-[16px] w-full opacity-0 animate-[appear_150ms_ease-in_forwards]">
|
<div className="message flex justify-end mt-[16px] w-full opacity-0 animate-[appear_150ms_ease-in_forwards]">
|
||||||
<div className="flex-col max-w-[85%]">
|
<div className="flex-col max-w-[85%] w-fit">
|
||||||
<div className="flex flex-col group">
|
<div className="flex flex-col group">
|
||||||
<div className="flex bg-slate text-white rounded-xl rounded-br-none py-2 px-3">
|
<div className="flex bg-background-accent text-text-on-accent rounded-xl py-2.5 px-4">
|
||||||
<div ref={contentRef}>
|
<div ref={contentRef}>
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={displayText}
|
content={displayText}
|
||||||
className="text-white prose-a:text-white prose-headings:text-white prose-strong:text-white prose-em:text-white user-message"
|
className="text-text-on-accent prose-a:text-text-on-accent prose-headings:text-text-on-accent prose-strong:text-text-on-accent prose-em:text-text-on-accent user-message"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,8 +52,8 @@ export default function UserMessage({ message }: UserMessageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative h-[22px] flex justify-end">
|
<div className="relative h-[22px] flex justify-end text-right">
|
||||||
<div className="absolute right-0 text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
<div className="absolute w-40 font-mono right-0 text-xs text-text-muted pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
||||||
{timestamp}
|
{timestamp}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 pt-1">
|
<div className="absolute right-0 pt-1">
|
||||||
|
|||||||
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 { FaCircle } from 'react-icons/fa';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
||||||
import { cn } from '../../utils';
|
import { cn } from '../../utils';
|
||||||
import { Alert, AlertType } from '../alerts';
|
import { Alert, AlertType } from '../alerts';
|
||||||
import { AlertBox } from '../alerts';
|
import { AlertBox } from '../alerts';
|
||||||
@@ -12,14 +11,73 @@ interface AlertPopoverProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [hasShownInitial, setHasShownInitial] = React.useState(false);
|
const [hasShownInitial, setHasShownInitial] = useState(false);
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [wasAutoShown, setWasAutoShown] = React.useState(false);
|
const [wasAutoShown, setWasAutoShown] = useState(false);
|
||||||
|
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
|
||||||
const previousAlertsRef = useRef<Alert[]>([]);
|
const previousAlertsRef = useRef<Alert[]>([]);
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Calculate popover position
|
||||||
|
const calculatePosition = useCallback(() => {
|
||||||
|
if (!triggerRef.current || !popoverRef.current) return;
|
||||||
|
|
||||||
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||||
|
const popoverWidth = 275;
|
||||||
|
|
||||||
|
// Get the actual rendered height of the popover
|
||||||
|
const popoverHeight = popoverRef.current.offsetHeight || 120;
|
||||||
|
const offset = 8; // Small gap to avoid blocking the trigger dot
|
||||||
|
|
||||||
|
// Position above the trigger, centered horizontally
|
||||||
|
let top = triggerRect.top - popoverHeight - offset;
|
||||||
|
let left = triggerRect.left + triggerRect.width / 2 - popoverWidth / 2;
|
||||||
|
|
||||||
|
// Ensure popover doesn't go off-screen
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
|
||||||
|
// Adjust horizontal position if off-screen
|
||||||
|
if (left < 10) {
|
||||||
|
left = 10;
|
||||||
|
} else if (left + popoverWidth > viewportWidth - 10) {
|
||||||
|
left = viewportWidth - popoverWidth - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If popover would go above viewport, show it below the trigger instead
|
||||||
|
if (top < 10) {
|
||||||
|
top = triggerRect.bottom + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPopoverPosition({ top, left });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update position when popover opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
calculatePosition();
|
||||||
|
// Recalculate on window resize
|
||||||
|
const handleResize = () => calculatePosition();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [isOpen, calculatePosition]);
|
||||||
|
|
||||||
|
// Recalculate position after popover is rendered to get actual height
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && popoverRef.current) {
|
||||||
|
// Small delay to ensure DOM is updated
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
calculatePosition();
|
||||||
|
}, 10);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [isOpen, calculatePosition]);
|
||||||
|
|
||||||
// Function to start the hide timer
|
// Function to start the hide timer
|
||||||
const startHideTimer = useCallback((duration = 3000) => {
|
const startHideTimer = useCallback((duration = 3000) => {
|
||||||
// Clear any existing timer
|
// Clear any existing timer
|
||||||
@@ -98,12 +156,14 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
|||||||
: 'text-[#cc4b03]'; // Orange color for warning alerts
|
: 'text-[#cc4b03]'; // Orange color for warning alerts
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={popoverRef}>
|
<>
|
||||||
<Popover open={isOpen}>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<PopoverTrigger asChild>
|
<button
|
||||||
<div
|
ref={triggerRef}
|
||||||
className="cursor-pointer flex items-center justify-center min-w-5 min-h-5 translate-y-[1px]"
|
className="cursor-pointer flex items-center justify-center min-w-5 min-h-5 rounded hover:bg-background-muted"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
@@ -122,33 +182,22 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={cn('relative', '-right-1', triggerColor)}>
|
<div className={cn('relative', triggerColor)}>
|
||||||
<FaCircle size={5} />
|
<FaCircle size={5} />
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
{/* Small connector area between trigger and content */}
|
{/* Popover rendered separately to avoid blocking clicks */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="absolute -right-2 h-6 w-8 top-full"
|
ref={popoverRef}
|
||||||
onMouseEnter={() => {
|
className="fixed w-[275px] p-0 rounded-lg overflow-hidden bg-app border z-50 shadow-lg pointer-events-auto text-left"
|
||||||
setIsHovered(true);
|
style={{
|
||||||
if (hideTimerRef.current) {
|
top: `${popoverPosition.top}px`,
|
||||||
clearTimeout(hideTimerRef.current);
|
left: `${popoverPosition.left}px`,
|
||||||
}
|
visibility: popoverPosition.top === 0 ? 'hidden' : 'visible',
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
|
||||||
setIsHovered(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
className="w-[275px] p-0 rounded-lg overflow-hidden"
|
|
||||||
align="end"
|
|
||||||
alignOffset={-100}
|
|
||||||
sideOffset={5}
|
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
if (hideTimerRef.current) {
|
if (hideTimerRef.current) {
|
||||||
@@ -167,9 +216,8 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useCallback, useState } from 'react';
|
||||||
|
import { Tornado } from 'lucide-react';
|
||||||
import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem';
|
import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem';
|
||||||
import { useConfig } from '../ConfigContext';
|
import { useConfig } from '../ConfigContext';
|
||||||
import { View, ViewOptions } from '../../App';
|
import {
|
||||||
import { Orbit } from 'lucide-react';
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '../ui/dropdown-menu';
|
||||||
|
|
||||||
interface BottomMenuModeSelectionProps {
|
export const BottomMenuModeSelection = () => {
|
||||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProps) => {
|
|
||||||
const [isGooseModeMenuOpen, setIsGooseModeMenuOpen] = useState(false);
|
|
||||||
const [gooseMode, setGooseMode] = useState('auto');
|
const [gooseMode, setGooseMode] = useState('auto');
|
||||||
const gooseModeDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const { read, upsert } = useConfig();
|
const { read, upsert } = useConfig();
|
||||||
|
|
||||||
const fetchCurrentMode = useCallback(async () => {
|
const fetchCurrentMode = useCallback(async () => {
|
||||||
@@ -29,41 +28,6 @@ export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProp
|
|||||||
fetchCurrentMode();
|
fetchCurrentMode();
|
||||||
}, [fetchCurrentMode]);
|
}, [fetchCurrentMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEsc = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setIsGooseModeMenuOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isGooseModeMenuOpen) {
|
|
||||||
window.addEventListener('keydown', handleEsc);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleEsc);
|
|
||||||
};
|
|
||||||
}, [isGooseModeMenuOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
gooseModeDropdownRef.current &&
|
|
||||||
!gooseModeDropdownRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setIsGooseModeMenuOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isGooseModeMenuOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [isGooseModeMenuOpen]);
|
|
||||||
|
|
||||||
const handleModeChange = async (newMode: string) => {
|
const handleModeChange = async (newMode: string) => {
|
||||||
if (gooseMode === newMode) {
|
if (gooseMode === newMode) {
|
||||||
return;
|
return;
|
||||||
@@ -83,35 +47,34 @@ export const BottomMenuModeSelection = ({ setView }: BottomMenuModeSelectionProp
|
|||||||
return mode ? mode.label : 'auto';
|
return mode ? mode.label : 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function getModeDescription(key: string) {
|
||||||
<div className="relative flex items-center" ref={gooseModeDropdownRef}>
|
const mode = all_goose_modes.find((mode) => mode.key === key);
|
||||||
<button
|
return mode ? mode.description : 'Automatic mode selection';
|
||||||
className="flex items-center justify-center text-textSubtle hover:text-textStandard h-6 [&_svg]:size-4"
|
}
|
||||||
onClick={() => setIsGooseModeMenuOpen(!isGooseModeMenuOpen)}
|
|
||||||
>
|
|
||||||
<span className="pr-1.5">{getValueByKey(gooseMode).toLowerCase()}</span>
|
|
||||||
<Orbit />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
return (
|
||||||
{isGooseModeMenuOpen && (
|
<div title={`Current mode: ${getValueByKey(gooseMode)} - ${getModeDescription(gooseMode)}`}>
|
||||||
<div className="absolute bottom-[24px] right-0 w-[240px] py-2 bg-bgApp rounded-lg border border-borderSubtle">
|
<DropdownMenu>
|
||||||
<div>
|
<DropdownMenuTrigger asChild>
|
||||||
|
<span className="flex items-center cursor-pointer [&_svg]:size-4 text-text-default/70 hover:text-text-default hover:scale-100 hover:bg-transparent text-xs">
|
||||||
|
<Tornado className="mr-1 h-4 w-4" />
|
||||||
|
{getValueByKey(gooseMode).toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-64" side="top" align="center">
|
||||||
{all_goose_modes.map((mode) => (
|
{all_goose_modes.map((mode) => (
|
||||||
|
<DropdownMenuItem key={mode.key} asChild>
|
||||||
<ModeSelectionItem
|
<ModeSelectionItem
|
||||||
key={mode.key}
|
|
||||||
mode={mode}
|
mode={mode}
|
||||||
currentMode={gooseMode}
|
currentMode={gooseMode}
|
||||||
showDescription={false}
|
showDescription={false}
|
||||||
isApproveModeConfigure={false}
|
isApproveModeConfigure={false}
|
||||||
parentView="chat"
|
|
||||||
setView={setView}
|
|
||||||
handleModeChange={handleModeChange}
|
handleModeChange={handleModeChange}
|
||||||
/>
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</DropdownMenuContent>
|
||||||
</div>
|
</DropdownMenu>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useModelAndProvider } from '../ModelAndProviderContext';
|
import { useModelAndProvider } from '../ModelAndProviderContext';
|
||||||
import { useConfig } from '../ConfigContext';
|
import { useConfig } from '../ConfigContext';
|
||||||
|
import { CoinIcon } from '../icons';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||||
import {
|
import {
|
||||||
getCostForModel,
|
getCostForModel,
|
||||||
initializeCostDatabase,
|
initializeCostDatabase,
|
||||||
@@ -170,8 +172,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatCost = (cost: number): string => {
|
const formatCost = (cost: number): string => {
|
||||||
// Always show 6 decimal places for consistency
|
// Always show 4 decimal places for consistency
|
||||||
return cost.toFixed(6);
|
return cost.toFixed(4);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading state or when we don't have model/provider info
|
// Show loading state or when we don't have model/provider info
|
||||||
@@ -182,9 +184,12 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
// If still loading, show a placeholder
|
// If still loading, show a placeholder
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px]">
|
<div className="flex items-center justify-center h-full text-textSubtle translate-y-[1px]">
|
||||||
<span className="text-xs font-mono">...</span>
|
<span className="text-xs font-mono">...</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-px h-4 bg-border-default mx-2" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,12 +202,20 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
const freeProviders = ['ollama', 'local', 'localhost'];
|
const freeProviders = ['ollama', 'local', 'localhost'];
|
||||||
if (freeProviders.includes(currentProvider.toLowerCase())) {
|
if (freeProviders.includes(currentProvider.toLowerCase())) {
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="flex items-center justify-center h-full text-textSubtle hover:text-textStandard transition-colors cursor-default translate-y-[1px]"
|
<Tooltip>
|
||||||
title={`Local model (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`}
|
<TooltipTrigger asChild>
|
||||||
>
|
<div className="flex items-center justify-center h-full text-text-default/70 hover:text-text-default transition-colors cursor-default translate-y-[1px]">
|
||||||
<span className="text-xs font-mono">$0.000000</span>
|
<CoinIcon className="mr-1" size={16} />
|
||||||
|
<span className="text-xs font-mono">0.0000</span>
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{`Local model (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="w-px h-4 bg-border-default mx-2" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,16 +229,18 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] ${
|
<Tooltip>
|
||||||
(pricingFailed || modelNotFound) && hasAttemptedFetch && initialLoadComplete
|
<TooltipTrigger asChild>
|
||||||
? 'text-red-500 hover:text-red-400'
|
<div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
|
||||||
: 'text-textSubtle hover:text-textStandard'
|
<CoinIcon className="mr-1" size={16} />
|
||||||
}`}
|
<span className="text-xs font-mono">0.0000</span>
|
||||||
title={getUnavailableTooltip()}
|
|
||||||
>
|
|
||||||
<span className="text-xs font-mono">$0.000000</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{getUnavailableTooltip()}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="w-px h-4 bg-border-default mx-2" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,18 +286,17 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={`flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] ${
|
<Tooltip>
|
||||||
(pricingFailed || modelNotFound) && hasAttemptedFetch && initialLoadComplete
|
<TooltipTrigger asChild>
|
||||||
? 'text-red-500 hover:text-red-400'
|
<div className="flex items-center justify-center h-full transition-colors cursor-default translate-y-[1px] text-text-default/70 hover:text-text-default">
|
||||||
: 'text-textSubtle hover:text-textStandard'
|
<CoinIcon className="mr-1" size={16} />
|
||||||
}`}
|
<span className="text-xs font-mono">{formatCost(totalCost)}</span>
|
||||||
title={getTooltipContent()}
|
|
||||||
>
|
|
||||||
<span className="text-xs font-mono">
|
|
||||||
{costInfo.currency || '$'}
|
|
||||||
{formatCost(totalCost)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{getTooltipContent()}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="w-px h-4 bg-border-default mx-2" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Message } from '../../types/message';
|
import { Message } from '../../types/message';
|
||||||
import { useChatContextManager } from './ChatContextManager';
|
import { useChatContextManager } from './ChatContextManager';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
interface ContextHandlerProps {
|
interface ContextHandlerProps {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
@@ -8,6 +9,7 @@ interface ContextHandlerProps {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
contextType: 'contextLengthExceeded' | 'summarizationRequested';
|
contextType: 'contextLengthExceeded' | 'summarizationRequested';
|
||||||
|
onSummaryComplete?: () => void; // Add callback for when summary is complete
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
||||||
@@ -16,6 +18,7 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
|||||||
chatId,
|
chatId,
|
||||||
workingDir,
|
workingDir,
|
||||||
contextType,
|
contextType,
|
||||||
|
onSummaryComplete,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
summaryContent,
|
summaryContent,
|
||||||
@@ -39,6 +42,33 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
|||||||
|
|
||||||
// Use a ref to track if we've started the fetch
|
// Use a ref to track if we've started the fetch
|
||||||
const fetchStartedRef = useRef(false);
|
const fetchStartedRef = useRef(false);
|
||||||
|
const hasCalledSummaryComplete = useRef(false);
|
||||||
|
|
||||||
|
// Call onSummaryComplete when summary is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (summaryContent && shouldAllowSummaryInteraction && !hasCalledSummaryComplete.current) {
|
||||||
|
hasCalledSummaryComplete.current = true;
|
||||||
|
// Delay the scroll slightly to ensure the content is rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
onSummaryComplete?.();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the flag when summary is cleared
|
||||||
|
if (!summaryContent) {
|
||||||
|
hasCalledSummaryComplete.current = false;
|
||||||
|
}
|
||||||
|
}, [summaryContent, shouldAllowSummaryInteraction, onSummaryComplete]);
|
||||||
|
|
||||||
|
// Scroll when summarization starts (loading state)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoadingSummary && shouldAllowSummaryInteraction) {
|
||||||
|
// Delay the scroll slightly to ensure the loading content is rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
onSummaryComplete?.();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [isLoadingSummary, shouldAllowSummaryInteraction, onSummaryComplete]);
|
||||||
|
|
||||||
// Function to trigger the async operation properly
|
// Function to trigger the async operation properly
|
||||||
const triggerContextLengthExceeded = () => {
|
const triggerContextLengthExceeded = () => {
|
||||||
@@ -122,12 +152,9 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
|||||||
? `This conversation has too much information to continue. Extension data often takes up significant space.`
|
? `This conversation has too much information to continue. Extension data often takes up significant space.`
|
||||||
: `Summarization failed. Continue chatting or start a new session.`}
|
: `Summarization failed. Continue chatting or start a new session.`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button onClick={openNewSession} className="text-xs transition-colors mt-1 flex items-center">
|
||||||
onClick={openNewSession}
|
|
||||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
|
||||||
>
|
|
||||||
Click here to start a new session
|
Click here to start a new session
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -138,12 +165,9 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
|||||||
? `Your conversation has exceeded the model's context capacity`
|
? `Your conversation has exceeded the model's context capacity`
|
||||||
: `Summarization requested`}
|
: `Summarization requested`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Button onClick={handleRetry} className="text-xs transition-colors mt-1 flex items-center">
|
||||||
onClick={handleRetry}
|
|
||||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
|
||||||
>
|
|
||||||
Retry loading summary
|
Retry loading summary
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,15 +184,15 @@ export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
|||||||
: `This summary includes key points from your conversation.`}
|
: `This summary includes key points from your conversation.`}
|
||||||
</span>
|
</span>
|
||||||
{shouldAllowSummaryInteraction && (
|
{shouldAllowSummaryInteraction && (
|
||||||
<button
|
<Button
|
||||||
onClick={openSummaryModal}
|
onClick={openSummaryModal}
|
||||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
className="text-xs transition-colors mt-1 flex items-center"
|
||||||
>
|
>
|
||||||
View or edit summary{' '}
|
View or edit summary{' '}
|
||||||
{isContextLengthExceeded
|
{isContextLengthExceeded
|
||||||
? '(you may continue your conversation based on the summary)'
|
? '(you may continue your conversation based on the summary)'
|
||||||
: ''}
|
: ''}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ScrollText } from 'lucide-react';
|
import { ScrollText } from 'lucide-react';
|
||||||
import Modal from '../Modal';
|
import { cn } from '../../utils';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||||
import { useChatContextManager } from './ChatContextManager';
|
import { useChatContextManager } from './ChatContextManager';
|
||||||
import { Message } from '../../types/message';
|
import { Message } from '../../types/message';
|
||||||
|
|
||||||
@@ -34,64 +43,66 @@ export const ManualSummarizeButton: React.FC<ManualSummarizeButtonProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Footer content for the confirmation modal
|
const handleClose = () => {
|
||||||
const footerContent = (
|
setIsConfirmationOpen(false);
|
||||||
<>
|
};
|
||||||
<Button
|
|
||||||
onClick={handleSummarize}
|
|
||||||
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-large"
|
|
||||||
>
|
|
||||||
Summarize
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsConfirmationOpen(false)}
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-large font-regular"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="w-px h-4 bg-border-default mx-2" />
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className={`flex items-center justify-center text-textSubtle hover:text-textStandard h-6 [&_svg]:size-4 ${
|
type="button"
|
||||||
isLoadingSummary || isLoading ? 'opacity-50 cursor-not-allowed' : ''
|
className={cn(
|
||||||
}`}
|
'flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors',
|
||||||
|
(isLoadingSummary || isLoading) &&
|
||||||
|
'cursor-not-allowed text-text-default/30 hover:text-text-default/30 opacity-50'
|
||||||
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={isLoadingSummary || isLoading}
|
disabled={isLoadingSummary || isLoading}
|
||||||
title="Summarize conversation context"
|
|
||||||
>
|
>
|
||||||
<ScrollText size={16} />
|
<ScrollText size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{isLoadingSummary ? 'Summarizing conversation...' : 'Summarize conversation context'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirmation Modal */}
|
{/* Confirmation Modal */}
|
||||||
{isConfirmationOpen && (
|
<Dialog open={isConfirmationOpen} onOpenChange={handleClose}>
|
||||||
<Modal footer={footerContent} onClose={() => setIsConfirmationOpen(false)}>
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<div className="flex flex-col mb-6">
|
<DialogHeader>
|
||||||
<div>
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<ScrollText className="text-iconStandard" size={24} />
|
<ScrollText className="text-iconStandard" size={24} />
|
||||||
</div>
|
Summarize Conversation
|
||||||
<div className="mt-2">
|
</DialogTitle>
|
||||||
<h2 className="text-2xl font-regular text-textStandard">Summarize Conversation</h2>
|
<DialogDescription>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<p className="text-textStandard mb-4">
|
|
||||||
This will summarize your conversation history to save context space.
|
This will summarize your conversation history to save context space.
|
||||||
</p>
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
<p className="text-textStandard">
|
<p className="text-textStandard">
|
||||||
Previous messages will remain visible but only the summary will be included in the
|
Previous messages will remain visible but only the summary will be included in the
|
||||||
active context for Goose. This is useful for long conversations that are approaching
|
active context for Goose. This is useful for long conversations that are approaching
|
||||||
the context limit.
|
the context limit.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
|
||||||
)}
|
<DialogFooter className="pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleSummarize}>
|
||||||
|
Summarize
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import { Card } from '../ui/card';
|
|
||||||
import { Geese } from '../icons/Geese';
|
import { Geese } from '../icons/Geese';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog';
|
||||||
|
|
||||||
interface SessionSummaryModalProps {
|
interface SessionSummaryModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -9,40 +17,6 @@ interface SessionSummaryModalProps {
|
|||||||
summaryContent: string;
|
summaryContent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a specialized version of BaseModal that's wider just for the SessionSummaryModal
|
|
||||||
function WiderBaseModal({
|
|
||||||
isOpen,
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
actions,
|
|
||||||
}: {
|
|
||||||
isOpen: boolean;
|
|
||||||
title: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
actions: React.ReactNode; // Buttons for actions
|
|
||||||
}) {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-[9999] flex items-center justify-center overflow-y-auto">
|
|
||||||
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[640px] max-h-[85vh] bg-white dark:bg-gray-800 rounded-xl shadow-xl overflow-hidden p-[16px] pt-[24px] pb-0 flex flex-col">
|
|
||||||
<div className="px-4 pb-0 space-y-8 flex-grow overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex">
|
|
||||||
<h2 className="text-2xl font-regular dark:text-white text-gray-900">{title}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - Make it scrollable */}
|
|
||||||
{children && <div className="px-2 overflow-y-auto max-h-[60vh]">{children}</div>}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">{actions}</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionSummaryModal({
|
export function SessionSummaryModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -65,29 +39,27 @@ export function SessionSummaryModal({
|
|||||||
onSave(currentText);
|
onSave(currentText);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Header Component - Icon, Title, and Description
|
return (
|
||||||
const Header = () => (
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<div className="flex flex-col items-center text-center mb-6">
|
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
|
||||||
{/* Icon */}
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex flex-col items-center text-center">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Geese width="48" height="50" />
|
<Geese width="48" height="50" />
|
||||||
</div>
|
</div>
|
||||||
|
Session Summary
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-center max-w-md">
|
||||||
|
This summary was created to manage your context limit. Review and edit to keep your
|
||||||
|
session running smoothly with the information that matters most.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Title */}
|
<div className="py-4">
|
||||||
<h2 className="text-xl font-medium text-gray-900 dark:text-white mb-2">Session Summary</h2>
|
<div className="w-full">
|
||||||
|
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-3">
|
||||||
{/* Description */}
|
Summarization
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-0 max-w-md">
|
</h3>
|
||||||
This summary was created to manage your context limit. Review and edit to keep your session
|
|
||||||
running smoothly with the information that matters most.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Uncontrolled Summary Content Component
|
|
||||||
const SummaryContent = () => (
|
|
||||||
<div className="w-full mb-6">
|
|
||||||
<h3 className="text-base font-medium text-gray-900 dark:text-white mb-3">Summarization</h3>
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -101,32 +73,15 @@ export function SessionSummaryModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
|
||||||
// Footer Buttons
|
<DialogFooter className="pt-2">
|
||||||
const modalActions = (
|
<Button variant="outline" onClick={onClose}>
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="w-full h-[60px] text-gray-900 dark:text-white font-medium text-base hover:bg-gray-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
Save and Continue
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="w-full h-[60px] text-gray-500 dark:text-gray-400 font-medium text-base hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
<Button onClick={handleSave}>Save and Continue</Button>
|
||||||
);
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
return (
|
</Dialog>
|
||||||
<WiderBaseModal isOpen={isOpen} title="" actions={modalActions}>
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
<Header />
|
|
||||||
<SummaryContent />
|
|
||||||
</div>
|
|
||||||
</WiderBaseModal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef, KeyboardEvent } from 'react';
|
|||||||
import { Search as SearchIcon } from 'lucide-react';
|
import { Search as SearchIcon } from 'lucide-react';
|
||||||
import { ArrowDown, ArrowUp, Close } from '../icons';
|
import { ArrowDown, ArrowUp, Close } from '../icons';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the SearchBar component
|
* Props for the SearchBar component
|
||||||
@@ -142,13 +143,13 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`sticky top-0 bg-bgAppInverse text-textProminentInverse z-50 ${
|
className={`sticky top-0 bg-background-inverse text-text-inverse z-50 mb-4 ${
|
||||||
isExiting ? 'search-bar-exit' : 'search-bar-enter'
|
isExiting ? 'search-bar-exit' : 'search-bar-enter'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex w-full max-w-5xl mx-auto">
|
<div className="flex w-full items-center">
|
||||||
<div className="relative flex flex-1 items-center h-full">
|
<div className="relative flex flex-1 items-center h-full min-w-0">
|
||||||
<SearchIcon className="h-4 w-4 text-textSubtleInverse absolute left-3" />
|
<SearchIcon className="h-4 w-4 text-text-inverse/70 absolute left-3" />
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -158,15 +159,15 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||||||
onChange={handleSearch}
|
onChange={handleSearch}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Search conversation..."
|
placeholder="Search conversation..."
|
||||||
className="w-full text-sm pl-9 pr-24 py-3 bg-bgAppInverse
|
className="w-full text-sm pl-9 pr-24 py-3 bg-background-inverse text-text-inverse
|
||||||
placeholder:text-textSubtleInverse focus:outline-none
|
placeholder:text-text-inverse/50 focus:outline-none
|
||||||
active:border-borderProminent"
|
active:border-border-strong"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute right-3 flex h-full items-center justify-end">
|
<div className="absolute right-3 flex h-full items-center justify-end">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-16 text-right text-sm text-textStandardInverse flex items-center justify-end">
|
<div className="w-16 text-right text-sm text-text-inverse/80 flex items-center justify-end">
|
||||||
{(() => {
|
{(() => {
|
||||||
return localSearchResults?.count && localSearchResults.count > 0 && searchTerm
|
return localSearchResults?.count && localSearchResults.count > 0 && searchTerm
|
||||||
? `${localSearchResults.currentIndex}/${localSearchResults.count}`
|
? `${localSearchResults.currentIndex}/${localSearchResults.count}`
|
||||||
@@ -177,47 +178,51 @@ export const SearchBar: React.FC<SearchBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center h-auto px-4 gap-2">
|
<div className="flex items-center justify-center h-auto px-4 gap-2 flex-shrink-0">
|
||||||
<button
|
<Button
|
||||||
onClick={toggleCaseSensitive}
|
onClick={toggleCaseSensitive}
|
||||||
|
variant="ghost"
|
||||||
className={`flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 ${
|
className={`flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 ${
|
||||||
caseSensitive
|
caseSensitive
|
||||||
? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)]'
|
? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)] text-text-inverse hover:bg-white/25'
|
||||||
: 'text-textSubtleInverse hover:text-textStandardInverse hover:bg-white/5'
|
: 'text-text-inverse/70 hover:text-text-inverse hover:bg-white/10'
|
||||||
}`}
|
}`}
|
||||||
title="Case Sensitive"
|
title="Case Sensitive"
|
||||||
>
|
>
|
||||||
<span className="text-md font-normal">Aa</span>
|
<span className="text-md font-normal">Aa</span>
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={(e) => handleNavigate('prev', e)} className="p-1" title="Previous (↑)">
|
<Button
|
||||||
|
onClick={(e) => handleNavigate('prev', e)}
|
||||||
|
variant="ghost"
|
||||||
|
className="flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
|
||||||
|
title="Previous (↑)"
|
||||||
|
>
|
||||||
<ArrowUp
|
<ArrowUp
|
||||||
className={`h-5 w-5 transition-opacity ${
|
className={`h-5 w-5 transition-opacity ${!hasResults ? 'opacity-30' : ''}`}
|
||||||
!hasResults
|
|
||||||
? 'opacity-30'
|
|
||||||
: 'text-textSubtleInverse hover:text-textStandardInverse'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => handleNavigate('next', e)}
|
onClick={(e) => handleNavigate('next', e)}
|
||||||
className="p-1"
|
variant="ghost"
|
||||||
|
className="flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
|
||||||
title="Next (↓ or Enter)"
|
title="Next (↓ or Enter)"
|
||||||
>
|
>
|
||||||
<ArrowDown
|
<ArrowDown
|
||||||
className={`h-5 w-5 transition-opacity ${
|
className={`h-5 w-5 transition-opacity ${!hasResults ? 'opacity-30' : ''}`}
|
||||||
!hasResults
|
|
||||||
? 'opacity-30'
|
|
||||||
: 'text-textSubtleInverse hover:text-textStandardInverse'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={handleClose} className="p-1" title="Close (Esc)">
|
<Button
|
||||||
<Close className="h-5 w-5 text-textSubtleInverse hover:text-textStandardInverse" />
|
onClick={handleClose}
|
||||||
</button>
|
variant="ghost"
|
||||||
|
className="flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<Close className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, PropsWithChildren, useCallback } from 'react';
|
import React, { useState, useEffect, PropsWithChildren, useCallback, useRef } from 'react';
|
||||||
import SearchBar from './SearchBar';
|
import SearchBar from './SearchBar';
|
||||||
import { SearchHighlighter } from '../../utils/searchHighlighter';
|
import { SearchHighlighter } from '../../utils/searchHighlighter';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
@@ -189,32 +189,76 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
|||||||
[internalSearchResults, onNavigate]
|
[internalSearchResults, onNavigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFindCommand = useCallback(() => {
|
// Create stable refs for the handlers to avoid memory leaks
|
||||||
|
const handlersRef = useRef({
|
||||||
|
handleFindCommand: () => {
|
||||||
if (isSearchVisible && searchInputRef.current) {
|
if (isSearchVisible && searchInputRef.current) {
|
||||||
searchInputRef.current.focus();
|
searchInputRef.current.focus();
|
||||||
searchInputRef.current.select();
|
searchInputRef.current.select();
|
||||||
} else {
|
} else {
|
||||||
setIsSearchVisible(true);
|
setIsSearchVisible(true);
|
||||||
}
|
}
|
||||||
}, [isSearchVisible]);
|
},
|
||||||
|
handleFindNext: () => {
|
||||||
const handleFindNext = useCallback(() => {
|
|
||||||
if (isSearchVisible) {
|
if (isSearchVisible) {
|
||||||
handleNavigate('next');
|
handleNavigate('next');
|
||||||
}
|
}
|
||||||
}, [isSearchVisible, handleNavigate]);
|
},
|
||||||
|
handleFindPrevious: () => {
|
||||||
const handleFindPrevious = useCallback(() => {
|
|
||||||
if (isSearchVisible) {
|
if (isSearchVisible) {
|
||||||
handleNavigate('prev');
|
handleNavigate('prev');
|
||||||
}
|
}
|
||||||
}, [isSearchVisible, handleNavigate]);
|
},
|
||||||
|
handleUseSelectionFind: () => {
|
||||||
const handleUseSelectionFind = useCallback(() => {
|
|
||||||
const selection = window.getSelection()?.toString().trim();
|
const selection = window.getSelection()?.toString().trim();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
setInitialSearchTerm(selection);
|
setInitialSearchTerm(selection);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the refs with current values
|
||||||
|
useEffect(() => {
|
||||||
|
handlersRef.current.handleFindCommand = () => {
|
||||||
|
if (isSearchVisible && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
searchInputRef.current.select();
|
||||||
|
} else {
|
||||||
|
setIsSearchVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handlersRef.current.handleFindNext = () => {
|
||||||
|
if (isSearchVisible) {
|
||||||
|
handleNavigate('next');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handlersRef.current.handleFindPrevious = () => {
|
||||||
|
if (isSearchVisible) {
|
||||||
|
handleNavigate('prev');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handlersRef.current.handleUseSelectionFind = () => {
|
||||||
|
const selection = window.getSelection()?.toString().trim();
|
||||||
|
if (selection) {
|
||||||
|
setInitialSearchTerm(selection);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFindCommand = useCallback(() => {
|
||||||
|
handlersRef.current.handleFindCommand();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFindNext = useCallback(() => {
|
||||||
|
handlersRef.current.handleFindNext();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFindPrevious = useCallback(() => {
|
||||||
|
handlersRef.current.handleFindPrevious();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUseSelectionFind = useCallback(() => {
|
||||||
|
handlersRef.current.handleUseSelectionFind();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -308,7 +352,8 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
|
|||||||
window.electron.off('find-previous', handleFindPrevious);
|
window.electron.off('find-previous', handleFindPrevious);
|
||||||
window.electron.off('use-selection-find', handleUseSelectionFind);
|
window.electron.off('use-selection-find', handleUseSelectionFind);
|
||||||
};
|
};
|
||||||
}, [handleFindCommand, handleFindNext, handleFindPrevious, handleUseSelectionFind]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Empty dependency array - handlers are stable due to useCallback and useRef
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
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 ChevronUp from './ChevronUp';
|
||||||
import { ChevronRight } from './ChevronRight';
|
import { ChevronRight } from './ChevronRight';
|
||||||
import Close from './Close';
|
import Close from './Close';
|
||||||
|
import CoinIcon from './CoinIcon';
|
||||||
import Copy from './Copy';
|
import Copy from './Copy';
|
||||||
|
import Discord from './Discord';
|
||||||
import Document from './Document';
|
import Document from './Document';
|
||||||
import Edit from './Edit';
|
import Edit from './Edit';
|
||||||
import Idea from './Idea';
|
import Idea from './Idea';
|
||||||
|
import LinkedIn from './LinkedIn';
|
||||||
import More from './More';
|
import More from './More';
|
||||||
import Refresh from './Refresh';
|
import Refresh from './Refresh';
|
||||||
import SensitiveHidden from './SensitiveHidden';
|
import SensitiveHidden from './SensitiveHidden';
|
||||||
@@ -20,6 +23,7 @@ import Send from './Send';
|
|||||||
import Settings from './Settings';
|
import Settings from './Settings';
|
||||||
import Time from './Time';
|
import Time from './Time';
|
||||||
import { Gear } from './Gear';
|
import { Gear } from './Gear';
|
||||||
|
import Youtube from './Youtube';
|
||||||
import { Microphone } from './Microphone';
|
import { Microphone } from './Microphone';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -33,12 +37,15 @@ export {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Close,
|
Close,
|
||||||
|
CoinIcon,
|
||||||
Copy,
|
Copy,
|
||||||
|
Discord,
|
||||||
Document,
|
Document,
|
||||||
Edit,
|
Edit,
|
||||||
Idea,
|
Idea,
|
||||||
Gear,
|
Gear,
|
||||||
Microphone,
|
Microphone,
|
||||||
|
LinkedIn,
|
||||||
More,
|
More,
|
||||||
Refresh,
|
Refresh,
|
||||||
SensitiveHidden,
|
SensitiveHidden,
|
||||||
@@ -46,4 +53,5 @@ export {
|
|||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
Time,
|
Time,
|
||||||
|
Youtube,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="parameter-input my-4 p-4 border rounded-lg bg-bgSubtle shadow-sm">
|
<div className="parameter-input my-4 p-4 border rounded-lg bg-bgSubtle shadow-sm">
|
||||||
<h3 className="text-lg font-bold text-textProminent mb-4">
|
<h3 className="text-lg font-bold text-textProminent mb-4">
|
||||||
Parameter: <code className="bg-bgApp px-2 py-1 rounded-md">{parameter.key}</code>
|
Parameter:{' '}
|
||||||
|
<code className="bg-background-default px-2 py-1 rounded-md">{parameter.key}</code>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -23,7 +24,7 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
|
|||||||
type="text"
|
type="text"
|
||||||
value={description || ''}
|
value={description || ''}
|
||||||
onChange={(e) => onChange(key, { description: e.target.value })}
|
onChange={(e) => onChange(key, { description: e.target.value })}
|
||||||
className="w-full p-3 border rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
className="w-full p-3 border rounded-lg bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
||||||
placeholder={`E.g., "Enter the name for the new component"`}
|
placeholder={`E.g., "Enter the name for the new component"`}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-textSubtle mt-1">This is the message the end-user will see.</p>
|
<p className="text-sm text-textSubtle mt-1">This is the message the end-user will see.</p>
|
||||||
@@ -34,7 +35,7 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-md text-textStandard mb-2 font-semibold">Requirement</label>
|
<label className="block text-md text-textStandard mb-2 font-semibold">Requirement</label>
|
||||||
<select
|
<select
|
||||||
className="w-full p-3 border rounded-lg bg-bgApp text-textStandard"
|
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
|
||||||
value={requirement}
|
value={requirement}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange(key, { requirement: e.target.value as Parameter['requirement'] })
|
onChange(key, { requirement: e.target.value as Parameter['requirement'] })
|
||||||
@@ -55,7 +56,7 @@ const ParameterInput: React.FC<ParameterInputProps> = ({ parameter, onChange })
|
|||||||
type="text"
|
type="text"
|
||||||
value={defaultValue}
|
value={defaultValue}
|
||||||
onChange={(e) => onChange(key, { default: e.target.value })}
|
onChange={(e) => onChange(key, { default: e.target.value })}
|
||||||
className="w-full p-3 border rounded-lg bg-bgApp text-textStandard"
|
className="w-full p-3 border rounded-lg bg-background-default text-textStandard"
|
||||||
placeholder="Enter default value"
|
placeholder="Enter default value"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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' },
|
{ value: '0', label: 'Sun' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1';
|
||||||
const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1';
|
const cronPreviewTextColor = 'text-xs text-text-subtle mt-1';
|
||||||
const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1';
|
const cronPreviewSpecialNoteColor = 'text-xs text-text-warning mt-1';
|
||||||
const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300';
|
const checkboxLabelClassName = 'flex items-center text-sm text-text-default';
|
||||||
const checkboxInputClassName =
|
const checkboxInputClassName =
|
||||||
'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2';
|
'h-4 w-4 text-accent-default border-border-subtle rounded focus:ring-accent-default mr-2';
|
||||||
|
|
||||||
type SourceType = 'file' | 'deeplink';
|
type SourceType = 'file' | 'deeplink';
|
||||||
type ExecutionMode = 'background' | 'foreground';
|
type ExecutionMode = 'background' | 'foreground';
|
||||||
@@ -543,15 +543,13 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
||||||
<div className="px-8 pt-8 pb-4 flex-shrink-0 text-center">
|
<div className="px-8 pt-8 pb-4 flex-shrink-0 text-center">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<img src={ClockIcon} alt="Clock" className="w-11 h-11 mb-2" />
|
<img src={ClockIcon} alt="Clock" className="w-11 h-11 mb-2" />
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-base font-semibold text-text-prominent">Create New Schedule</h2>
|
||||||
Create New Schedule
|
<p className="text-base text-text-subtle mt-2 max-w-sm">
|
||||||
</h2>
|
|
||||||
<p className="text-base text-gray-500 dark:text-gray-400 mt-2 max-w-sm">
|
|
||||||
Create a new schedule using the settings below to do things like automatically run
|
Create a new schedule using the settings below to do things like automatically run
|
||||||
tasks or create files
|
tasks or create files
|
||||||
</p>
|
</p>
|
||||||
@@ -564,12 +562,12 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
|||||||
className="px-8 py-4 space-y-4 flex-grow overflow-y-auto"
|
className="px-8 py-4 space-y-4 flex-grow overflow-y-auto"
|
||||||
>
|
>
|
||||||
{apiErrorExternally && (
|
{apiErrorExternally && (
|
||||||
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
|
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
|
||||||
{apiErrorExternally}
|
{apiErrorExternally}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{internalValidationError && (
|
{internalValidationError && (
|
||||||
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
|
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
|
||||||
{internalValidationError}
|
{internalValidationError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -348,8 +348,8 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
||||||
<div className="px-6 pt-6 pb-4 flex-shrink-0">
|
<div className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
Edit Schedule: {schedule?.id || ''}
|
Edit Schedule: {schedule?.id || ''}
|
||||||
@@ -538,9 +538,9 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="edit-schedule-form"
|
form="edit-schedule-form"
|
||||||
variant="default"
|
variant="ghost"
|
||||||
disabled={isLoadingExternally}
|
disabled={isLoadingExternally}
|
||||||
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-lg dark:text-white dark:border-gray-600 font-regular"
|
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
|
||||||
>
|
>
|
||||||
{isLoadingExternally ? 'Updating...' : 'Update Schedule'}
|
{isLoadingExternally ? 'Updating...' : 'Update Schedule'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Button } from '../ui/button';
|
|||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
import BackButton from '../ui/BackButton';
|
import BackButton from '../ui/BackButton';
|
||||||
import { Card } from '../ui/card';
|
import { Card } from '../ui/card';
|
||||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
|
||||||
import { fetchSessionDetails, SessionDetails } from '../../sessions';
|
import { fetchSessionDetails, SessionDetails } from '../../sessions';
|
||||||
import {
|
import {
|
||||||
getScheduleSessions,
|
getScheduleSessions,
|
||||||
@@ -67,12 +66,10 @@ const ScheduleInfoCard = React.memo<{
|
|||||||
}, [scheduleDetails.process_start_time]);
|
}, [scheduleDetails.process_start_time]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4 bg-white dark:bg-gray-800 shadow mb-6">
|
<Card className="p-4 bg-background-card shadow mb-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-base font-semibold text-text-prominent">{scheduleDetails.id}</h3>
|
||||||
{scheduleDetails.id}
|
|
||||||
</h3>
|
|
||||||
<div className="mt-2 md:mt-0 flex items-center gap-2">
|
<div className="mt-2 md:mt-0 flex items-center gap-2">
|
||||||
{scheduleDetails.currently_running && (
|
{scheduleDetails.currently_running && (
|
||||||
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
|
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
|
||||||
@@ -88,20 +85,20 @@ const ScheduleInfoCard = React.memo<{
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-text-default">
|
||||||
<span className="font-semibold">Schedule:</span> {readableCron}
|
<span className="font-semibold">Schedule:</span> {readableCron}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-text-default">
|
||||||
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
|
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-text-default">
|
||||||
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
|
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-text-default">
|
||||||
<span className="font-semibold">Last Run:</span> {formattedLastRun}
|
<span className="font-semibold">Last Run:</span> {formattedLastRun}
|
||||||
</p>
|
</p>
|
||||||
{scheduleDetails.execution_mode && (
|
{scheduleDetails.execution_mode && (
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-text-default">
|
||||||
<span className="font-semibold">Execution Mode:</span>{' '}
|
<span className="font-semibold">Execution Mode:</span>{' '}
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
@@ -115,13 +112,13 @@ const ScheduleInfoCard = React.memo<{
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
|
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-text-default">
|
||||||
<span className="font-semibold">Current Session:</span>{' '}
|
<span className="font-semibold">Current Session:</span>{' '}
|
||||||
{scheduleDetails.current_session_id}
|
{scheduleDetails.current_session_id}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{scheduleDetails.currently_running && formattedProcessStartTime && (
|
{scheduleDetails.currently_running && formattedProcessStartTime && (
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-sm text-text-default">
|
||||||
<span className="font-semibold">Process Started:</span> {formattedProcessStartTime}
|
<span className="font-semibold">Process Started:</span> {formattedProcessStartTime}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -486,13 +483,10 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
|||||||
|
|
||||||
if (!scheduleId) {
|
if (!scheduleId) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full flex flex-col items-center justify-center bg-app text-textStandard p-8">
|
<div className="h-screen w-full flex flex-col items-center justify-center bg-white dark:bg-gray-900 text-text-default p-8">
|
||||||
<MoreMenuLayout showMenu={false} />
|
|
||||||
<BackButton onClick={onNavigateBack} />
|
<BackButton onClick={onNavigateBack} />
|
||||||
<h1 className="text-2xl font-medium text-gray-900 dark:text-white mt-4">
|
<h1 className="text-2xl font-medium text-text-prominent mt-4">Schedule Not Found</h1>
|
||||||
Schedule Not Found
|
<p className="text-text-subtle mt-2">
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
|
||||||
No schedule ID was provided. Please return to the schedules list and select a schedule.
|
No schedule ID was provided. Please return to the schedules list and select a schedule.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,31 +494,24 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
|
<div className="h-screen w-full flex flex-col bg-background-default text-text-default">
|
||||||
<MoreMenuLayout showMenu={false} />
|
<div className="px-8 pt-6 pb-4 border-b border-border-subtle flex-shrink-0">
|
||||||
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
|
|
||||||
<BackButton onClick={onNavigateBack} />
|
<BackButton onClick={onNavigateBack} />
|
||||||
<h1 className="text-3xl font-medium text-gray-900 dark:text-white mt-1">
|
<h1 className="text-3xl font-medium text-text-prominent mt-1">Schedule Details</h1>
|
||||||
Schedule Details
|
<p className="text-sm text-text-subtle mt-1">Viewing Schedule ID: {scheduleId}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Viewing Schedule ID: {scheduleId}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-grow">
|
<ScrollArea className="flex-grow">
|
||||||
<div className="p-8 space-y-6">
|
<div className="p-8 space-y-6">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
<h2 className="text-xl font-semibold text-text-prominent mb-3">Schedule Information</h2>
|
||||||
Schedule Information
|
|
||||||
</h2>
|
|
||||||
{isLoadingSchedule && (
|
{isLoadingSchedule && (
|
||||||
<div className="flex items-center text-gray-500 dark:text-gray-400">
|
<div className="flex items-center text-text-subtle">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule details...
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule details...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{scheduleError && (
|
{scheduleError && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm p-3 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
|
<p className="text-text-error text-sm p-3 bg-background-error border border-border-error rounded-md">
|
||||||
Error: {scheduleError}
|
Error: {scheduleError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -534,7 +521,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">Actions</h2>
|
<h2 className="text-xl font-semibold text-text-prominent mb-3">Actions</h2>
|
||||||
<div className="flex flex-col md:flex-row gap-2">
|
<div className="flex flex-col md:flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRunNow}
|
onClick={handleRunNow}
|
||||||
@@ -619,19 +606,17 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-xl font-semibold text-text-prominent mb-4">
|
||||||
Recent Sessions for this Schedule
|
Recent Sessions for this Schedule
|
||||||
</h2>
|
</h2>
|
||||||
{isLoadingSessions && (
|
{isLoadingSessions && <p className="text-text-subtle">Loading sessions...</p>}
|
||||||
<p className="text-gray-500 dark:text-gray-400">Loading sessions...</p>
|
|
||||||
)}
|
|
||||||
{sessionsError && (
|
{sessionsError && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm p-3 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
|
<p className="text-text-error text-sm p-3 bg-background-error border border-border-error rounded-md">
|
||||||
Error: {sessionsError}
|
Error: {sessionsError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
|
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
|
<p className="text-text-subtle text-center py-4">
|
||||||
No sessions found for this schedule.
|
No sessions found for this schedule.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -641,7 +626,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
|||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<Card
|
<Card
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
className="p-4 bg-background-card shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||||
onClick={() => handleSessionCardClick(session.id)}
|
onClick={() => handleSessionCardClick(session.id)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -652,23 +637,23 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
className="text-sm font-semibold text-gray-900 dark:text-white truncate"
|
className="text-sm font-semibold text-text-prominent truncate"
|
||||||
title={session.name || session.id}
|
title={session.name || session.id}
|
||||||
>
|
>
|
||||||
{session.name || `Session ID: ${session.id}`}{' '}
|
{session.name || `Session ID: ${session.id}`}{' '}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-text-subtle mt-1">
|
||||||
Created:{' '}
|
Created:{' '}
|
||||||
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
|
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
|
||||||
</p>
|
</p>
|
||||||
{session.messageCount !== undefined && (
|
{session.messageCount !== undefined && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-text-subtle mt-1">
|
||||||
Messages: {session.messageCount}
|
Messages: {session.messageCount}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{session.workingDir && (
|
{session.workingDir && (
|
||||||
<p
|
<p
|
||||||
className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"
|
className="text-xs text-text-subtle mt-1 truncate"
|
||||||
title={session.workingDir}
|
title={session.workingDir}
|
||||||
>
|
>
|
||||||
Dir: {session.workingDir}
|
Dir: {session.workingDir}
|
||||||
@@ -676,11 +661,11 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
|||||||
)}
|
)}
|
||||||
{session.accumulatedTotalTokens !== undefined &&
|
{session.accumulatedTotalTokens !== undefined &&
|
||||||
session.accumulatedTotalTokens !== null && (
|
session.accumulatedTotalTokens !== null && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-text-subtle mt-1">
|
||||||
Tokens: {session.accumulatedTotalTokens}
|
Tokens: {session.accumulatedTotalTokens}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-500 mt-1">
|
<p className="text-xs text-text-muted mt-1">
|
||||||
ID: <span className="font-mono">{session.id}</span>
|
ID: <span className="font-mono">{session.id}</span>
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ export const ScheduleFromRecipeModal: React.FC<ScheduleFromRecipeModalProps> = (
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-lg z-50 flex flex-col">
|
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-lg z-50 flex flex-col">
|
||||||
<div className="px-6 pt-6 pb-4">
|
<div className="px-6 pt-6 pb-4">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
Create Schedule from Recipe
|
Create Schedule from Recipe
|
||||||
@@ -123,7 +123,7 @@ export const ScheduleFromRecipeModal: React.FC<ScheduleFromRecipeModalProps> = (
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateSchedule}
|
onClick={handleCreateSchedule}
|
||||||
className="flex-1 bg-bgAppInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
className="flex-1 bg-background-defaultInverse text-sm text-textProminentInverse rounded-xl hover:bg-bgStandardInverse transition-colors"
|
||||||
>
|
>
|
||||||
Create Schedule
|
Create Schedule
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,23 +10,21 @@ import {
|
|||||||
inspectRunningJob,
|
inspectRunningJob,
|
||||||
ScheduledJob,
|
ScheduledJob,
|
||||||
} from '../../schedule';
|
} from '../../schedule';
|
||||||
import BackButton from '../ui/BackButton';
|
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
|
||||||
import { Card } from '../ui/card';
|
import { Card } from '../ui/card';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { TrashIcon } from '../icons/TrashIcon';
|
import { TrashIcon } from '../icons/TrashIcon';
|
||||||
import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye, MoreHorizontal } from 'lucide-react';
|
import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye, CircleDotDashed } from 'lucide-react';
|
||||||
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
|
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
|
||||||
import { EditScheduleModal } from './EditScheduleModal';
|
import { EditScheduleModal } from './EditScheduleModal';
|
||||||
import ScheduleDetailView from './ScheduleDetailView';
|
import ScheduleDetailView from './ScheduleDetailView';
|
||||||
import { toastError, toastSuccess } from '../../toasts';
|
import { toastError, toastSuccess } from '../../toasts';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
||||||
import cronstrue from 'cronstrue';
|
import cronstrue from 'cronstrue';
|
||||||
import { formatToLocalDateWithTimezone } from '../../utils/date';
|
import { formatToLocalDateWithTimezone } from '../../utils/date';
|
||||||
|
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||||
|
|
||||||
interface SchedulesViewProps {
|
interface SchedulesViewProps {
|
||||||
onClose: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memoized ScheduleCard component to prevent unnecessary re-renders
|
// Memoized ScheduleCard component to prevent unnecessary re-renders
|
||||||
@@ -75,89 +73,64 @@ const ScheduleCard = React.memo<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150"
|
||||||
onClick={() => onNavigateToDetail(job.id)}
|
onClick={() => onNavigateToDetail(job.id)}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start gap-4">
|
||||||
<div className="flex-grow mr-2 overflow-hidden">
|
<div className="min-w-0 flex-1">
|
||||||
<h3
|
<div className="flex items-center gap-2 mb-1">
|
||||||
className="text-base font-semibold text-gray-900 dark:text-white truncate"
|
<h3 className="text-base truncate max-w-[50vw]" title={job.id}>
|
||||||
title={job.id}
|
|
||||||
>
|
|
||||||
{job.id}
|
{job.id}
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
|
||||||
className="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all"
|
|
||||||
title={job.source}
|
|
||||||
>
|
|
||||||
Source: {job.source}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1" title={readableCron}>
|
|
||||||
Schedule: {readableCron}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Last Run: {formattedLastRun}
|
|
||||||
</p>
|
|
||||||
{job.execution_mode && (
|
{job.execution_mode && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
Mode:{' '}
|
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
job.execution_mode === 'foreground'
|
job.execution_mode === 'foreground'
|
||||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
? 'bg-background-accent text-text-on-accent'
|
||||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
: 'bg-background-medium text-text-default'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{job.execution_mode === 'foreground' ? '🖥️ Foreground' : '⚡ Background'}
|
{job.execution_mode === 'foreground' ? '🖥️' : '⚡'}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{job.currently_running && (
|
{job.currently_running && (
|
||||||
<p className="text-xs text-green-500 dark:text-green-400 mt-1 font-semibold flex items-center">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||||
Currently Running
|
Running
|
||||||
</p>
|
</span>
|
||||||
)}
|
)}
|
||||||
{job.paused && (
|
{job.paused && (
|
||||||
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1 font-semibold flex items-center">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||||
<Pause className="w-3 h-3 mr-1" />
|
<Pause className="w-3 h-3 mr-1" />
|
||||||
Paused
|
Paused
|
||||||
</p>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<p className="text-text-muted text-sm mb-2 line-clamp-2" title={readableCron}>
|
||||||
<Popover>
|
{readableCron}
|
||||||
<PopoverTrigger asChild>
|
</p>
|
||||||
<Button
|
<div className="flex items-center text-xs text-text-muted">
|
||||||
variant="ghost"
|
<span>Last run: {formattedLastRun}</span>
|
||||||
size="icon"
|
</div>
|
||||||
onClick={(e) => {
|
</div>
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100/50 dark:hover:bg-gray-800/50"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-48 p-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 shadow-lg"
|
|
||||||
align="end"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{!job.currently_running && (
|
{!job.currently_running && (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEdit(job);
|
onEdit(job);
|
||||||
}}
|
}}
|
||||||
disabled={isPausing || isDeleting || isSubmitting}
|
disabled={isPausing || isDeleting || isSubmitting}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
<span>Edit</span>
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
<Edit className="w-4 h-4" />
|
Edit
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (job.paused) {
|
if (job.paused) {
|
||||||
@@ -167,54 +140,66 @@ const ScheduleCard = React.memo<{
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isPausing || isDeleting}
|
disabled={isPausing || isDeleting}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
<span>{job.paused ? 'Resume schedule' : 'Stop schedule'}</span>
|
{job.paused ? (
|
||||||
{job.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
<>
|
||||||
</button>
|
<Play className="w-4 h-4 mr-1" />
|
||||||
|
Resume
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pause className="w-4 h-4 mr-1" />
|
||||||
|
Pause
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{job.currently_running && (
|
{job.currently_running && (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onInspect(job.id);
|
onInspect(job.id);
|
||||||
}}
|
}}
|
||||||
disabled={isInspecting || isKilling}
|
disabled={isInspecting || isKilling}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
<span>Inspect</span>
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
<Eye className="w-4 h-4" />
|
Inspect
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onKill(job.id);
|
onKill(job.id);
|
||||||
}}
|
}}
|
||||||
disabled={isKilling || isInspecting}
|
disabled={isKilling || isInspecting}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
<span>Kill job</span>
|
<Square className="w-4 h-4 mr-1" />
|
||||||
<Square className="w-4 h-4" />
|
Kill
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<hr className="border-gray-200 dark:border-gray-600 my-1" />
|
<Button
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(job.id);
|
onDelete(job.id);
|
||||||
}}
|
}}
|
||||||
disabled={isPausing || isDeleting || isKilling || isInspecting}
|
disabled={isPausing || isDeleting || isKilling || isInspecting}
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
>
|
>
|
||||||
<span>Delete</span>
|
|
||||||
<TrashIcon className="w-4 h-4" />
|
<TrashIcon className="w-4 h-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -224,7 +209,7 @@ const ScheduleCard = React.memo<{
|
|||||||
|
|
||||||
ScheduleCard.displayName = 'ScheduleCard';
|
ScheduleCard.displayName = 'ScheduleCard';
|
||||||
|
|
||||||
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||||
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
|
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -573,57 +558,64 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
|
<>
|
||||||
<MoreMenuLayout showMenu={false} />
|
<MainPanelLayout>
|
||||||
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<BackButton onClick={onClose} />
|
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mt-2">
|
<div className="flex flex-col page-transition">
|
||||||
Schedules Management
|
<div className="flex justify-between items-center mb-1">
|
||||||
</h1>
|
<h1 className="text-4xl font-light">Scheduler</h1>
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
|
|
||||||
<ScrollArea className="flex-grow">
|
|
||||||
<div className="p-8">
|
|
||||||
<div className="flex flex-col md:flex-row gap-2 mb-8">
|
|
||||||
<Button
|
|
||||||
onClick={handleOpenCreateModal}
|
|
||||||
className="w-full md:w-auto flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" /> Create New Schedule
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isRefreshing || isLoading}
|
disabled={isRefreshing || isLoading}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full md:w-auto flex items-center gap-2 justify-center rounded-full [&>svg]:!size-4"
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Schedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-muted mb-1">
|
||||||
|
Create and manage scheduled tasks to run recipes automatically at specified times.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 relative px-8">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="h-full relative">
|
||||||
{apiError && (
|
{apiError && (
|
||||||
<p className="text-red-500 dark:text-red-400 text-sm p-4 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
|
<div className="mb-4 p-4 bg-background-error border border-border-error rounded-md">
|
||||||
Error: {apiError}
|
<p className="text-text-error text-sm">Error: {apiError}</p>
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
|
||||||
Existing Schedules
|
|
||||||
</h2>
|
|
||||||
{isLoading && schedules.length === 0 && (
|
{isLoading && schedules.length === 0 && (
|
||||||
<p className="text-gray-500 dark:text-gray-400">Loading schedules...</p>
|
<div className="flex justify-center items-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-text-default"></div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !apiError && schedules.length === 0 && (
|
{!isLoading && !apiError && schedules.length === 0 && (
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
|
<div className="flex flex-col pt-4 pb-12">
|
||||||
No schedules found. Create one to get started!
|
<CircleDotDashed className="h-5 w-5 text-text-muted mb-3.5" />
|
||||||
</p>
|
<p className="text-base text-text-muted font-light mb-2">No schedules yet</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && schedules.length > 0 && (
|
{!isLoading && schedules.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="space-y-2 pb-8">
|
||||||
{schedules.map((job) => (
|
{schedules.map((job) => (
|
||||||
<ScheduleCard
|
<ScheduleCard
|
||||||
key={job.id}
|
key={job.id}
|
||||||
@@ -644,9 +636,12 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainPanelLayout>
|
||||||
|
|
||||||
<CreateScheduleModal
|
<CreateScheduleModal
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={handleCloseCreateModal}
|
onClose={handleCloseCreateModal}
|
||||||
@@ -662,7 +657,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
|||||||
isLoadingExternally={isSubmitting}
|
isLoadingExternally={isSubmitting}
|
||||||
apiErrorExternally={submitApiError}
|
apiErrorExternally={submitApiError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,65 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Target,
|
Target,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronLeft,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { type SessionDetails } from '../../sessions';
|
import { type SessionDetails } from '../../sessions';
|
||||||
import { SessionHeaderCard, SessionMessages } from './SessionViewComponents';
|
|
||||||
import { formatMessageTimestamp } from '../../utils/timeUtils';
|
|
||||||
import { createSharedSession } from '../../sharedSessions';
|
|
||||||
import { Modal, ModalContent } from '../ui/modal';
|
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||||
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
|
import { formatMessageTimestamp } from '../../utils/timeUtils';
|
||||||
|
import { createSharedSession } from '../../sharedSessions';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog';
|
||||||
|
import ProgressiveMessageList from '../ProgressiveMessageList';
|
||||||
|
import { SearchView } from '../conversation/SearchView';
|
||||||
|
import { ChatContextManagerProvider } from '../context_management/ChatContextManager';
|
||||||
|
import { Message } from '../../types/message';
|
||||||
|
|
||||||
|
// Helper function to determine if a message is a user message (same as useChatEngine)
|
||||||
|
const isUserMessage = (message: Message): boolean => {
|
||||||
|
if (message.role === 'assistant') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter messages for display (same logic as useChatEngine)
|
||||||
|
const filterMessagesForDisplay = (messages: Message[]): Message[] => {
|
||||||
|
return messages.filter((message) => {
|
||||||
|
// Only filter out when display is explicitly false
|
||||||
|
if (message.display === false) return false;
|
||||||
|
|
||||||
|
// Keep all assistant messages and user messages that aren't just tool responses
|
||||||
|
if (message.role === 'assistant') return true;
|
||||||
|
|
||||||
|
// For user messages, check if they're only tool responses
|
||||||
|
if (message.role === 'user') {
|
||||||
|
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
|
||||||
|
const hasTextContent = message.content.some((c) => c.type === 'text');
|
||||||
|
const hasToolConfirmation = message.content.every(
|
||||||
|
(c) => c.type === 'toolConfirmationRequest'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the message if it has text content or tool confirmation or is not just tool responses
|
||||||
|
return hasTextContent || !hasOnlyToolResponses || hasToolConfirmation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface SessionHistoryViewProps {
|
interface SessionHistoryViewProps {
|
||||||
session: SessionDetails;
|
session: SessionDetails;
|
||||||
@@ -29,6 +79,94 @@ interface SessionHistoryViewProps {
|
|||||||
showActionButtons?: boolean;
|
showActionButtons?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom SessionHeader component similar to SessionListView style
|
||||||
|
const SessionHeader: React.FC<{
|
||||||
|
onBack: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
actionButtons?: React.ReactNode;
|
||||||
|
}> = ({ onBack, children, title, actionButtons }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col pb-8 border-b">
|
||||||
|
<div className="flex items-center pt-13 pb-2">
|
||||||
|
<Button onClick={onBack} size="xs" variant="outline">
|
||||||
|
<ChevronLeft />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-light mb-4">{title}</h1>
|
||||||
|
<div className="flex items-center">{children}</div>
|
||||||
|
{actionButtons && <div className="flex items-center space-x-3 mt-4">{actionButtons}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Session messages component that uses the same rendering as BaseChat
|
||||||
|
const SessionMessages: React.FC<{
|
||||||
|
messages: Message[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
}> = ({ messages, isLoading, error, onRetry }) => {
|
||||||
|
// Filter messages for display (same as BaseChat)
|
||||||
|
const filteredMessages = filterMessagesForDisplay(messages);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full w-full">
|
||||||
|
<div className="pb-24 pt-8">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center items-center py-12">
|
||||||
|
<LoaderCircle className="animate-spin h-8 w-8 text-textStandard" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
|
||||||
|
<div className="text-red-500 mb-4">
|
||||||
|
<AlertCircle size={32} />
|
||||||
|
</div>
|
||||||
|
<p className="text-md mb-2">Error Loading Session Details</p>
|
||||||
|
<p className="text-sm text-center mb-4">{error}</p>
|
||||||
|
<Button onClick={onRetry} variant="default">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : filteredMessages?.length > 0 ? (
|
||||||
|
<ChatContextManagerProvider>
|
||||||
|
<div className="max-w-4xl mx-auto w-full">
|
||||||
|
<SearchView>
|
||||||
|
<ProgressiveMessageList
|
||||||
|
messages={filteredMessages}
|
||||||
|
chat={{
|
||||||
|
id: 'session-preview',
|
||||||
|
messageHistoryIndex: filteredMessages.length,
|
||||||
|
}}
|
||||||
|
toolCallNotifications={new Map()}
|
||||||
|
append={() => {}} // Read-only for session history
|
||||||
|
appendMessage={(newMessage) => {
|
||||||
|
// Read-only - do nothing
|
||||||
|
console.log('appendMessage called in read-only session history:', newMessage);
|
||||||
|
}}
|
||||||
|
isUserMessage={isUserMessage} // Use the same function as BaseChat
|
||||||
|
batchSize={15} // Same as BaseChat default
|
||||||
|
batchDelay={30} // Same as BaseChat default
|
||||||
|
showLoadingThreshold={30} // Same as BaseChat default
|
||||||
|
/>
|
||||||
|
</SearchView>
|
||||||
|
</div>
|
||||||
|
</ChatContextManagerProvider>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
|
||||||
|
<MessageSquareText className="w-12 h-12 mb-4" />
|
||||||
|
<p className="text-lg mb-2">No messages found</p>
|
||||||
|
<p className="text-sm">This session doesn't contain any messages</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||||
session,
|
session,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -106,16 +244,81 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleLaunchInNewWindow = () => {
|
||||||
<div className="h-screen w-full flex flex-col">
|
if (session) {
|
||||||
<MoreMenuLayout showMenu={false} />
|
console.log('Launching session in new window:', session.session_id);
|
||||||
|
console.log('Session details:', session);
|
||||||
|
|
||||||
<SessionHeaderCard onBack={onBack}>
|
// Get the working directory from the session metadata
|
||||||
<div className="ml-8">
|
const workingDir = session.metadata?.working_dir;
|
||||||
<h1 className="text-lg text-textStandardInverse">
|
|
||||||
{session.metadata.description || session.session_id}
|
if (workingDir) {
|
||||||
</h1>
|
console.log(
|
||||||
<div className="flex items-center text-sm text-textSubtle mt-1 space-x-5">
|
`Opening new window with session ID: ${session.session_id}, in working dir: ${workingDir}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a new chat window with the working directory and session ID
|
||||||
|
window.electron.createChatWindow(
|
||||||
|
undefined, // query
|
||||||
|
workingDir, // dir
|
||||||
|
undefined, // version
|
||||||
|
session.session_id // resumeSessionId
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('createChatWindow called successfully');
|
||||||
|
} else {
|
||||||
|
console.error('No working directory found in session metadata');
|
||||||
|
toast.error('Could not launch session: Missing working directory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define action buttons
|
||||||
|
const actionButtons = showActionButtons ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleShare}
|
||||||
|
disabled={!canShare || isSharing}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className={canShare ? '' : 'cursor-not-allowed opacity-50'}
|
||||||
|
>
|
||||||
|
{isSharing ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Sharing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
Share
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onResume} size="sm" variant="outline">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleLaunchInNewWindow} size="sm" variant="outline">
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
New Window
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MainPanelLayout>
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 px-8">
|
||||||
|
<SessionHeader
|
||||||
|
onBack={onBack}
|
||||||
|
title={session.metadata.description || 'Session Details'}
|
||||||
|
actionButtons={!isLoading ? actionButtons : null}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{!isLoading && session.messages.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center text-text-muted text-sm space-x-5 font-mono">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Calendar className="w-4 h-4 mr-1" />
|
<Calendar className="w-4 h-4 mr-1" />
|
||||||
{formatMessageTimestamp(session.messages[0]?.created)}
|
{formatMessageTimestamp(session.messages[0]?.created)}
|
||||||
@@ -131,48 +334,21 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-textSubtle space-x-5">
|
<div className="flex items-center text-text-muted text-sm mt-1 font-mono">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Folder className="w-4 h-4 mr-1" />
|
<Folder className="w-4 h-4 mr-1" />
|
||||||
{session.metadata.working_dir}
|
{session.metadata.working_dir}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{showActionButtons && (
|
|
||||||
<div className="ml-auto flex items-center space-x-4">
|
|
||||||
<button
|
|
||||||
onClick={handleShare}
|
|
||||||
title="Share Session"
|
|
||||||
disabled={!canShare || isSharing}
|
|
||||||
className={`flex items-center text-textStandardInverse px-2 py-1 ${
|
|
||||||
canShare
|
|
||||||
? 'hover:font-bold hover:scale-110 transition-all duration-150'
|
|
||||||
: 'cursor-not-allowed opacity-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isSharing ? (
|
|
||||||
<>
|
|
||||||
<LoaderCircle className="w-7 h-7 animate-spin mr-2" />
|
|
||||||
<span>Sharing...</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center text-text-muted text-sm">
|
||||||
<Share2 className="w-7 h-7" />
|
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
|
||||||
</>
|
<span>Loading session details...</span>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onResume}
|
|
||||||
title="Resume Session"
|
|
||||||
className="flex items-center text-textStandardInverse px-2 py-1 hover:font-bold hover:scale-110 transition-all duration-150"
|
|
||||||
>
|
|
||||||
<Sparkles className="w-7 h-7" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SessionHeaderCard>
|
</div>
|
||||||
|
</SessionHeader>
|
||||||
|
|
||||||
<SessionMessages
|
<SessionMessages
|
||||||
messages={session.messages}
|
messages={session.messages}
|
||||||
@@ -180,28 +356,28 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
|||||||
error={error}
|
error={error}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</MainPanelLayout>
|
||||||
|
|
||||||
<Modal open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
|
<Dialog open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
|
||||||
<ModalContent className="sm:max-w-md p-0 bg-bgApp dark:bg-bgApp dark:border-borderSubtle">
|
<DialogContent className="sm:max-w-md">
|
||||||
<div className="flex justify-center mt-4">
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex justify-center items-center gap-2">
|
||||||
<Share2 className="w-6 h-6 text-textStandard" />
|
<Share2 className="w-6 h-6 text-textStandard" />
|
||||||
</div>
|
Share Session (beta)
|
||||||
|
</DialogTitle>
|
||||||
<div className="mt-2 px-6 text-center">
|
<DialogDescription>
|
||||||
<h2 className="text-lg font-semibold text-textStandard">Share Session (beta)</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-6 flex flex-col gap-4 mt-2">
|
|
||||||
<p className="text-sm text-center text-textSubtle">
|
|
||||||
Share this session link to give others a read only view of your goose chat.
|
Share this session link to give others a read only view of your goose chat.
|
||||||
</p>
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
<div className="relative rounded-lg border border-borderSubtle px-3 py-2 flex items-center bg-gray-100 dark:bg-gray-600">
|
<div className="relative rounded-lg border border-borderSubtle px-3 py-2 flex items-center bg-gray-100 dark:bg-gray-600">
|
||||||
<code className="text-sm text-textStandard dark:text-textStandardInverse overflow-x-hidden break-all pr-8 w-full">
|
<code className="text-sm text-textStandard dark:text-textStandardInverse overflow-x-hidden break-all pr-8 w-full">
|
||||||
{shareLink}
|
{shareLink}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||||
onClick={handleCopyLink}
|
onClick={handleCopyLink}
|
||||||
@@ -213,19 +389,14 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setIsShareModalOpen(false)}>
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsShareModalOpen(false)}
|
|
||||||
className="w-full h-[60px] border-t rounded-b-lg dark:border-gray-600 text-lg text-textStandard hover:bg-gray-100 hover:dark:bg-gray-600"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</ModalContent>
|
</DialogContent>
|
||||||
</Modal>
|
</Dialog>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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