diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index cafc23d496b8..5095cb38b309 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -7,14 +7,14 @@ use crate::state::AppState; use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode}, - routing::get, + routing::{get, put}, Json, Router, }; use goose::message::Message; use goose::session; use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder}; use goose::session::SessionMetadata; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tracing::{error, info}; use utoipa::ToSchema; @@ -36,6 +36,15 @@ pub struct SessionHistoryResponse { messages: Vec, } +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSessionMetadataRequest { + /// Updated description (name) for the session (max 200 characters) + description: String, +} + +const MAX_DESCRIPTION_LENGTH: usize = 200; + #[derive(Serialize, ToSchema, Debug)] #[serde(rename_all = "camelCase")] pub struct SessionInsights { @@ -300,6 +309,56 @@ async fn get_activity_heatmap( Ok(Json(result)) } +#[utoipa::path( + put, + path = "/sessions/{session_id}/metadata", + request_body = UpdateSessionMetadataRequest, + params( + ("session_id" = String, Path, description = "Unique identifier for the session") + ), + responses( + (status = 200, description = "Session metadata updated successfully"), + (status = 400, description = "Bad request - Description too long (max 200 characters)"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Session not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +// Update session metadata +async fn update_session_metadata( + State(state): State>, + headers: HeaderMap, + Path(session_id): Path, + Json(request): Json, +) -> Result { + verify_secret_key(&headers, &state)?; + + // Validate description length + if request.description.len() > MAX_DESCRIPTION_LENGTH { + return Err(StatusCode::BAD_REQUEST); + } + + let session_path = session::get_path(session::Identifier::Name(session_id.clone())) + .map_err(|_| StatusCode::BAD_REQUEST)?; + + // Read current metadata + let mut metadata = session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?; + + // Update description + metadata.description = request.description; + + // Save updated metadata + session::update_metadata(&session_path, &metadata) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::OK) +} + // Configure routes for this module pub fn routes(state: Arc) -> Router { Router::new() @@ -307,5 +366,67 @@ pub fn routes(state: Arc) -> Router { .route("/sessions/{session_id}", get(get_session_history)) .route("/sessions/insights", get(get_session_insights)) .route("/sessions/activity-heatmap", get(get_activity_heatmap)) + .route( + "/sessions/{session_id}/metadata", + put(update_session_metadata), + ) .with_state(state) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_update_session_metadata_request_deserialization() { + // Test that our request struct can be deserialized properly + let json = r#"{"description": "test description"}"#; + let request: UpdateSessionMetadataRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.description, "test description"); + } + + #[tokio::test] + async fn test_update_session_metadata_request_validation() { + // Test empty description + let empty_request = UpdateSessionMetadataRequest { + description: "".to_string(), + }; + assert_eq!(empty_request.description, ""); + + // Test normal description + let normal_request = UpdateSessionMetadataRequest { + description: "My Session Name".to_string(), + }; + assert_eq!(normal_request.description, "My Session Name"); + + // Test description at max length (should be valid) + let max_length_description = "A".repeat(MAX_DESCRIPTION_LENGTH); + let max_request = UpdateSessionMetadataRequest { + description: max_length_description.clone(), + }; + assert_eq!(max_request.description, max_length_description); + assert_eq!(max_request.description.len(), MAX_DESCRIPTION_LENGTH); + + // Test description over max length + let over_max_description = "A".repeat(MAX_DESCRIPTION_LENGTH + 1); + let over_max_request = UpdateSessionMetadataRequest { + description: over_max_description.clone(), + }; + assert_eq!(over_max_request.description, over_max_description); + assert!(over_max_request.description.len() > MAX_DESCRIPTION_LENGTH); + } + + #[tokio::test] + async fn test_description_length_validation() { + // Test the validation logic used in the endpoint + let valid_description = "A".repeat(MAX_DESCRIPTION_LENGTH); + assert!(valid_description.len() <= MAX_DESCRIPTION_LENGTH); + + let invalid_description = "A".repeat(MAX_DESCRIPTION_LENGTH + 1); + assert!(invalid_description.len() > MAX_DESCRIPTION_LENGTH); + + // Test edge cases + assert!(String::new().len() <= MAX_DESCRIPTION_LENGTH); // Empty string + assert!("Short".len() <= MAX_DESCRIPTION_LENGTH); // Short string + } +} diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 9b4e1f73fa71..0d4597088099 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef, useCallback, useMemo, startTransition } from 'react'; -import { MessageSquareText, Target, AlertCircle, Calendar, Folder } from 'lucide-react'; -import { fetchSessions, type Session } from '../../sessions'; +import { MessageSquareText, Target, AlertCircle, Calendar, Folder, Edit2 } from 'lucide-react'; +import { fetchSessions, updateSessionMetadata, type Session } from '../../sessions'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { ScrollArea } from '../ui/scroll-area'; @@ -11,6 +11,125 @@ import { SearchHighlighter } from '../../utils/searchHighlighter'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils'; import { Skeleton } from '../ui/skeleton'; +import { toast } from 'react-toastify'; + +interface EditSessionModalProps { + session: Session | null; + isOpen: boolean; + onClose: () => void; + onSave: (sessionId: string, newDescription: string) => Promise; + disabled?: boolean; +} + +const EditSessionModal = React.memo( + ({ session, isOpen, onClose, onSave, disabled = false }) => { + const [description, setDescription] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + if (session && isOpen) { + setDescription(session.metadata.description || session.id); + } else if (!isOpen) { + // Reset state when modal closes + setDescription(''); + setIsUpdating(false); + } + }, [session, isOpen]); + + const handleSave = useCallback(async () => { + if (!session || disabled) return; + + const trimmedDescription = description.trim(); + if (trimmedDescription === session.metadata.description) { + onClose(); + return; + } + + setIsUpdating(true); + try { + await updateSessionMetadata(session.id, trimmedDescription); + await onSave(session.id, trimmedDescription); + + // Close modal, then show success toast on a timeout to let the UI update complete. + onClose(); + setTimeout(() => { + toast.success('Session description updated successfully'); + }, 300); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + console.error('Failed to update session description:', errorMessage); + toast.error(`Failed to update session description: ${errorMessage}`); + // Reset to original description on error + setDescription(session.metadata.description || session.id); + } finally { + setIsUpdating(false); + } + }, [session, description, onSave, onClose, disabled]); + + const handleCancel = useCallback(() => { + if (!isUpdating) { + onClose(); + } + }, [onClose, isUpdating]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !isUpdating) { + handleSave(); + } else if (e.key === 'Escape' && !isUpdating) { + handleCancel(); + } + }, + [handleSave, handleCancel, isUpdating] + ); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setDescription(e.target.value); + }, []); + + if (!isOpen || !session) return null; + + return ( +
+
+

Edit Session Description

+ +
+
+ +
+
+ +
+ + +
+
+
+ ); + } +); + +EditSessionModal.displayName = 'EditSessionModal'; // Debounce hook for search function useDebounce(value: T, delay: number): T { @@ -52,6 +171,10 @@ const SessionListView: React.FC = React.memo(({ onSelectSe currentIndex: number; } | null>(null); + // Edit modal state + const [showEditModal, setShowEditModal] = useState(false); + const [editingSession, setEditingSession] = useState(null); + // Search state for debouncing const [searchTerm, setSearchTerm] = useState(''); const [caseSensitive, setCaseSensitive] = useState(false); @@ -184,15 +307,63 @@ const SessionListView: React.FC = React.memo(({ onSelectSe } }; - // Render a session item - const SessionItem = React.memo(function SessionItem({ session }: { session: Session }) { + // Handle modal close + const handleModalClose = useCallback(() => { + setShowEditModal(false); + setEditingSession(null); + }, []); + + const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => { + // Update state immediately for optimistic UI + setSessions((prevSessions) => + prevSessions.map((s) => + s.id === sessionId ? { ...s, metadata: { ...s.metadata, description: newDescription } } : s + ) + ); + }, []); + + const handleEditSession = useCallback((session: Session) => { + setEditingSession(session); + setShowEditModal(true); + }, []); + + const SessionItem = React.memo(function SessionItem({ + session, + onEditClick, + }: { + session: Session; + onEditClick: (session: Session) => void; + }) { + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + onEditClick(session); + }, + [onEditClick, session] + ); + + const handleCardClick = useCallback(() => { + onSelectSession(session.id); + }, [session.id]); + return ( onSelectSession(session.id)} - className="session-item h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between" + onClick={handleCardClick} + className="session-item h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between relative group" > + +
-

{session.metadata.description || session.id}

+

+ {session.metadata.description || session.id} +

+
{formatMessageTimestamp(Date.parse(session.modified) / 1000)} @@ -303,7 +474,7 @@ const SessionListView: React.FC = React.memo(({ onSelectSe
{group.sessions.map((session) => ( - + ))}
@@ -313,88 +484,97 @@ const SessionListView: React.FC = React.memo(({ onSelectSe }; return ( - -
-
-
-
-

Chat history

+ <> + +
+
+
+
+

Chat history

+
+

+ View and search your past conversations with Goose. +

-

- View and search your past conversations with Goose. -

-
-
- -
- - {/* Skeleton layer - always rendered but conditionally visible */} -
+ +
+ -
- {/* Today section */} -
- -
- - - - - + {/* Skeleton layer - always rendered but conditionally visible */} +
+
+ {/* Today section */} +
+ +
+ + + + + +
-
- {/* Yesterday section */} -
- -
- - - - - - + {/* Yesterday section */} +
+ +
+ + + + + + +
-
- {/* Additional section */} -
- -
- - - + {/* Additional section */} +
+ +
+ + + +
-
- {/* Content layer - always rendered but conditionally visible */} -
- {renderActualContent()} -
- -
- + {/* Content layer - always rendered but conditionally visible */} +
+ {renderActualContent()} +
+ +
+ +
-
- + + + + ); }); diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 749f8bc73cf0..5f7bd90483d5 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,6 +1,7 @@ import { Message } from './types/message'; import { getSessionHistory, listSessions, SessionInfo } from './api'; import { convertApiMessageToFrontendMessage } from './components/context_management'; +import { getApiUrl } from './config'; export interface SessionMetadata { description: string; @@ -126,3 +127,33 @@ export async function fetchSessionDetails(sessionId: string): Promise { + try { + const url = getApiUrl(`/sessions/${sessionId}/metadata`); + const secretKey = await window.electron.getSecretKey(); + + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': secretKey, + }, + body: JSON.stringify({ description }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to update session metadata: ${response.statusText} - ${errorText}`); + } + } catch (error) { + console.error(`Error updating session metadata for ${sessionId}:`, error); + throw error; + } +}