UI update with sidebar and settings tabs (#3288)

Co-authored-by: Nahiyan Khan <nahiyan@squareup.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Lily Delalande <119957291+lily-de@users.noreply.github.com>
Co-authored-by: Spence <spencrmartin@gmail.com>
Co-authored-by: spencrmartin <spencermartin@squareup.com>
Co-authored-by: Judson Stephenson <Jud@users.noreply.github.com>
Co-authored-by: Max Novich <mnovich@squareup.com>
Co-authored-by: Best Codes <106822363+The-Best-Codes@users.noreply.github.com>
Co-authored-by: caroline-a-mckenzie <cmckenzie@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Zane
2025-07-15 17:24:41 -07:00
committed by GitHub
parent b22f50d1a1
commit 77ea27f5f5
195 changed files with 20633 additions and 7238 deletions
+2
View File
@@ -5,6 +5,7 @@ pub mod config_management;
pub mod context;
pub mod extension;
pub mod health;
pub mod project;
pub mod recipe;
pub mod reply;
pub mod schedule;
@@ -27,4 +28,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
.merge(recipe::routes(state.clone()))
.merge(session::routes(state.clone()))
.merge(schedule::routes(state.clone()))
.merge(project::routes(state.clone()))
}
+358
View File
@@ -0,0 +1,358 @@
use super::utils::verify_secret_key;
use std::sync::Arc;
use crate::state::AppState;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{delete, get, post, put},
Json, Router,
};
use goose::project::{Project, ProjectMetadata};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateProjectRequest {
/// Display name of the project
pub name: String,
/// Optional description of the project
pub description: Option<String>,
/// Default working directory for sessions in this project
#[schema(value_type = String)]
pub default_directory: std::path::PathBuf,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectRequest {
/// Display name of the project
pub name: Option<String>,
/// Optional description of the project
pub description: Option<Option<String>>,
/// Default working directory for sessions in this project
#[schema(value_type = String)]
pub default_directory: Option<std::path::PathBuf>,
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProjectListResponse {
/// List of available project metadata objects
pub projects: Vec<ProjectMetadata>,
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProjectResponse {
/// Project details
pub project: Project,
}
#[utoipa::path(
get,
path = "/projects",
responses(
(status = 200, description = "List of available projects retrieved successfully", body = ProjectListResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Project Management"
)]
// List all available projects
async fn list_projects(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<ProjectListResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;
let projects =
goose::project::list_projects().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ProjectListResponse { projects }))
}
#[utoipa::path(
get,
path = "/projects/{project_id}",
params(
("project_id" = String, Path, description = "Unique identifier for the project")
),
responses(
(status = 200, description = "Project details retrieved successfully", body = ProjectResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Project not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Project Management"
)]
// Get a specific project details
async fn get_project_details(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(project_id): Path<String>,
) -> Result<Json<ProjectResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;
let project = goose::project::get_project(&project_id).map_err(|e| {
if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
Ok(Json(ProjectResponse { project }))
}
#[utoipa::path(
post,
path = "/projects",
request_body = CreateProjectRequest,
responses(
(status = 201, description = "Project created successfully", body = ProjectResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 400, description = "Invalid request - Bad input parameters"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Project Management"
)]
// Create a new project
async fn create_project(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(create_req): Json<CreateProjectRequest>,
) -> Result<Json<ProjectResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;
// Validate input
if create_req.name.trim().is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
let project = goose::project::create_project(
create_req.name,
create_req.description,
create_req.default_directory,
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ProjectResponse { project }))
}
#[utoipa::path(
put,
path = "/projects/{project_id}",
params(
("project_id" = String, Path, description = "Unique identifier for the project")
),
request_body = UpdateProjectRequest,
responses(
(status = 200, description = "Project updated successfully", body = ProjectResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Project not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Project Management"
)]
// Update a project
async fn update_project(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(project_id): Path<String>,
Json(update_req): Json<UpdateProjectRequest>,
) -> Result<Json<ProjectResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;
let project = goose::project::update_project(
&project_id,
update_req.name,
update_req.description,
update_req.default_directory,
)
.map_err(|e| {
if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
Ok(Json(ProjectResponse { project }))
}
#[utoipa::path(
delete,
path = "/projects/{project_id}",
params(
("project_id" = String, Path, description = "Unique identifier for the project")
),
responses(
(status = 204, description = "Project deleted successfully"),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Project not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Project Management"
)]
// Delete a project
async fn delete_project(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(project_id): Path<String>,
) -> Result<StatusCode, StatusCode> {
verify_secret_key(&headers, &state)?;
goose::project::delete_project(&project_id).map_err(|e| {
if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/projects/{project_id}/sessions/{session_id}",
params(
("project_id" = String, Path, description = "Unique identifier for the project"),
("session_id" = String, Path, description = "Unique identifier for the session to add")
),
responses(
(status = 204, description = "Session added to project successfully"),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Project or session not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Project Management"
)]
// Add session to project
async fn add_session_to_project(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path((project_id, session_id)): Path<(String, String)>,
) -> Result<StatusCode, StatusCode> {
verify_secret_key(&headers, &state)?;
// Add the session to project
goose::project::add_session_to_project(&project_id, &session_id).map_err(|e| {
if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
// Also update session metadata to include the project_id
let session_path =
goose::session::get_path(goose::session::Identifier::Name(session_id.clone()))
.map_err(|_| StatusCode::NOT_FOUND)?;
let mut metadata =
goose::session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
metadata.project_id = Some(project_id);
tokio::task::spawn(async move {
if let Err(e) = goose::session::update_metadata(&session_path, &metadata).await {
tracing::error!("Failed to update session metadata: {}", e);
}
});
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
delete,
path = "/projects/{project_id}/sessions/{session_id}",
params(
("project_id" = String, Path, description = "Unique identifier for the project"),
("session_id" = String, Path, description = "Unique identifier for the session to remove")
),
responses(
(status = 204, description = "Session removed from project successfully"),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Project or session not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Project Management"
)]
// Remove session from project
async fn remove_session_from_project(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path((project_id, session_id)): Path<(String, String)>,
) -> Result<StatusCode, StatusCode> {
verify_secret_key(&headers, &state)?;
// Remove from project
goose::project::remove_session_from_project(&project_id, &session_id).map_err(|e| {
if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
// Also update session metadata to remove the project_id
let session_path =
goose::session::get_path(goose::session::Identifier::Name(session_id.clone()))
.map_err(|_| StatusCode::NOT_FOUND)?;
let mut metadata =
goose::session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
// Only update if this session was actually in this project
if metadata.project_id.as_deref() == Some(&project_id) {
metadata.project_id = None;
tokio::task::spawn(async move {
if let Err(e) = goose::session::update_metadata(&session_path, &metadata).await {
tracing::error!("Failed to update session metadata: {}", e);
}
});
}
Ok(StatusCode::NO_CONTENT)
}
// Configure routes for this module
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/projects", get(list_projects))
.route("/projects", post(create_project))
.route("/projects/{project_id}", get(get_project_details))
.route("/projects/{project_id}", put(update_project))
.route("/projects/{project_id}", delete(delete_project))
.route(
"/projects/{project_id}/sessions/{session_id}",
post(add_session_to_project),
)
.route(
"/projects/{project_id}/sessions/{session_id}",
delete(remove_session_from_project),
)
.with_state(state)
}
+190
View File
@@ -1,4 +1,6 @@
use super::utils::verify_secret_key;
use chrono::{DateTime, Datelike};
use std::collections::HashMap;
use std::sync::Arc;
use crate::state::AppState;
@@ -13,6 +15,7 @@ use goose::session;
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
use goose::session::SessionMetadata;
use serde::Serialize;
use tracing::{error, info};
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
@@ -33,6 +36,29 @@ pub struct SessionHistoryResponse {
messages: Vec<Message>,
}
#[derive(Serialize, ToSchema, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SessionInsights {
/// Total number of sessions
total_sessions: usize,
/// Most active working directories with session counts
most_active_dirs: Vec<(String, usize)>,
/// Average session duration in minutes
avg_session_duration: f64,
/// Total tokens used across all sessions
total_tokens: i64,
/// Activity trend for the last 7 days
recent_activity: Vec<(String, usize)>,
}
#[derive(Serialize, ToSchema, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ActivityHeatmapCell {
pub week: usize,
pub day: usize,
pub count: usize,
}
#[utoipa::path(
get,
path = "/sessions",
@@ -106,10 +132,174 @@ async fn get_session_history(
}))
}
#[utoipa::path(
get,
path = "/sessions/insights",
responses(
(status = 200, description = "Session insights retrieved successfully", body = SessionInsights),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn get_session_insights(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<SessionInsights>, StatusCode> {
info!("Received request for session insights");
verify_secret_key(&headers, &state)?;
let sessions = get_valid_sorted_sessions(SortOrder::Descending).map_err(|e| {
error!("Failed to get session info: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Filter out sessions without descriptions
let sessions: Vec<SessionInfo> = sessions
.into_iter()
.filter(|session| !session.metadata.description.is_empty())
.collect();
info!("Found {} sessions with descriptions", sessions.len());
// Calculate insights
let total_sessions = sessions.len();
// Debug: Log if we have very few sessions, which might indicate filtering issues
if total_sessions == 0 {
info!("Warning: No sessions found with descriptions");
}
// Track directory usage
let mut dir_counts: HashMap<String, usize> = HashMap::new();
let mut total_duration = 0.0;
let mut total_tokens = 0;
let mut activity_by_date: HashMap<String, usize> = HashMap::new();
for session in &sessions {
// Track directory usage
let dir = session.metadata.working_dir.to_string_lossy().to_string();
*dir_counts.entry(dir).or_insert(0) += 1;
// Track tokens - only add positive values to prevent negative totals
if let Some(tokens) = session.metadata.accumulated_total_tokens {
if tokens > 0 {
total_tokens += tokens as i64;
} else if tokens < 0 {
// Log negative token values for debugging
info!(
"Warning: Session {} has negative accumulated_total_tokens: {}",
session.id, tokens
);
}
}
// Track activity by date
if let Ok(date) = DateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC") {
let date_str = date.format("%Y-%m-%d").to_string();
*activity_by_date.entry(date_str).or_insert(0) += 1;
}
// Calculate session duration from messages
let session_path = session::get_path(session::Identifier::Name(session.id.clone()));
if let Ok(session_path) = session_path {
if let Ok(messages) = session::read_messages(&session_path) {
if let (Some(first), Some(last)) = (messages.first(), messages.last()) {
let duration = (last.created - first.created) as f64 / 60.0; // Convert to minutes
total_duration += duration;
}
}
}
}
// Get top 3 most active directories
let mut dir_vec: Vec<(String, usize)> = dir_counts.into_iter().collect();
dir_vec.sort_by(|a, b| b.1.cmp(&a.1));
let most_active_dirs = dir_vec.into_iter().take(3).collect();
// Calculate average session duration
let avg_session_duration = if total_sessions > 0 {
total_duration / total_sessions as f64
} else {
0.0
};
// Get last 7 days of activity
let mut activity_vec: Vec<(String, usize)> = activity_by_date.into_iter().collect();
activity_vec.sort_by(|a, b| b.0.cmp(&a.0)); // Sort by date descending
let recent_activity = activity_vec.into_iter().take(7).collect();
let insights = SessionInsights {
total_sessions,
most_active_dirs,
avg_session_duration,
total_tokens,
recent_activity,
};
info!("Returning insights: {:?}", insights);
Ok(Json(insights))
}
#[utoipa::path(
get,
path = "/sessions/activity-heatmap",
responses(
(status = 200, description = "Activity heatmap data", body = [ActivityHeatmapCell]),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 500, description = "Internal server error")
),
security(("api_key" = [])),
tag = "Session Management"
)]
async fn get_activity_heatmap(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<Vec<ActivityHeatmapCell>>, StatusCode> {
verify_secret_key(&headers, &state)?;
let sessions = get_valid_sorted_sessions(SortOrder::Descending)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Only sessions with a description
let sessions: Vec<SessionInfo> = sessions
.into_iter()
.filter(|session| !session.metadata.description.is_empty())
.collect();
// Map: (week, day) -> count
let mut heatmap: std::collections::HashMap<(usize, usize), usize> =
std::collections::HashMap::new();
for session in &sessions {
if let Ok(date) =
chrono::NaiveDateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC")
{
let date = date.date();
let week = date.iso_week().week() as usize - 1; // 0-based week
let day = date.weekday().num_days_from_sunday() as usize; // 0=Sun, 6=Sat
*heatmap.entry((week, day)).or_insert(0) += 1;
}
}
let mut result = Vec::new();
for ((week, day), count) in heatmap {
result.push(ActivityHeatmapCell { week, day, count });
}
Ok(Json(result))
}
// Configure routes for this module
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/sessions", get(list_sessions))
.route("/sessions/{session_id}", get(get_session_history))
.route("/sessions/insights", get(get_session_insights))
.route("/sessions/activity-heatmap", get(get_activity_heatmap))
.with_state(state)
}