diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index ca0d59703058..1b4ea48d6b71 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -5,14 +5,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 utoipa::ToSchema; #[derive(Serialize, ToSchema)] @@ -33,6 +33,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; + #[utoipa::path( get, path = "/sessions", @@ -106,10 +115,119 @@ async fn get_session_history( })) } +#[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() .route("/sessions", get(list_sessions)) .route("/sessions/{session_id}", get(get_session_history)) + .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/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 6c34d069c53b..a89f8966a81c 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -25,6 +25,7 @@ import LayingEggLoader from './LayingEggLoader'; import { fetchSessionDetails, generateSessionId } from '../sessions'; import 'react-toastify/dist/ReactToastify.css'; import { useMessageStream } from '../hooks/useMessageStream'; +import { useSessionMetadata } from '../hooks/useSessionMetadata'; import { SessionSummaryModal } from './context_management/SessionSummaryModal'; import ParameterInputModal from './ParameterInputModal'; import { Recipe } from '../recipe'; @@ -154,6 +155,10 @@ function ChatContent({ getContextHandlerType, } = useChatContextManager(); + // Use the session metadata hook + const { sessionName, isSessionNameSet, refreshSessionName, updateSessionName } = + useSessionMetadata(chat.id); + useEffect(() => { // Log all messages when the component first mounts window.electron.logInfo( @@ -231,6 +236,18 @@ function ChatContent({ }, }); + // Refresh session name after messages are added (for auto-generated names) + useEffect(() => { + if (messages.length > 0 && !isSessionNameSet) { + // Delay to allow server to process and generate name + const timer = setTimeout(() => { + refreshSessionName(); + }, 2000); + return () => window.clearTimeout(timer); + } + return undefined; + }, [messages.length, isSessionNameSet, refreshSessionName]); + // Wrap append to store messages in global history const append = useCallback( (messageOrString: Message | string) => { @@ -754,6 +771,15 @@ function ChatContent({ hasMessages={hasMessages} setView={setView} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} + sessionId={chat.id} + sessionName={sessionName} + onSessionNameUpdated={(newName) => { + updateSessionName(newName); + setChat({ + ...chat, + title: newName, + }); + }} /> void; setIsGoosehintsModalOpen?: (isOpen: boolean) => void; + sessionId?: string; + sessionName?: string | null; + onSessionNameUpdated?: (newName: string) => void; }) { const [isTooltipOpen, setIsTooltipOpen] = useState(false); @@ -29,40 +36,50 @@ export default function MoreMenuLayout({
- - - - - - - {window.appConfig.get('GOOSE_WORKING_DIR') as string} - - - + + + {String(window.appConfig.get('GOOSE_WORKING_DIR'))} + + + + + {window.appConfig.get('GOOSE_WORKING_DIR') as string} + + + + + {sessionId && sessionName && ( + + )} +
{})} diff --git a/ui/desktop/src/components/session/SessionHeader.tsx b/ui/desktop/src/components/session/SessionHeader.tsx new file mode 100644 index 000000000000..b9287de147cc --- /dev/null +++ b/ui/desktop/src/components/session/SessionHeader.tsx @@ -0,0 +1,206 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { updateSessionMetadata } from '../../sessions'; +import { toastSuccess, toastError } from '../../toasts'; +import { Edit2, MessageCircleMore } from 'lucide-react'; + +interface SessionHeaderProps { + sessionId: string; + sessionName: string; + onNameUpdated?: (newName: string) => void; +} + +const MAX_DESCRIPTION_LENGTH = 200; + +export function SessionHeader({ sessionId, sessionName, onNameUpdated }: SessionHeaderProps) { + const [isEditing, setIsEditing] = useState(false); + const [name, setName] = useState(sessionName); + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Update local name when prop changes + useEffect(() => { + setName(sessionName); + }, [sessionName]); + + // Focus input when editing starts + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + // Handle click outside to save + useEffect(() => { + if (!isEditing) return; + + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + // Call handleSave directly here instead of referencing it + if (name.trim() === '') { + toastError({ title: 'Session name cannot be empty' }); + setName(sessionName); // Reset to original + return; + } + + if (name.trim().length > MAX_DESCRIPTION_LENGTH) { + toastError({ title: `Session name too long (max ${MAX_DESCRIPTION_LENGTH} characters)` }); + setName(sessionName); // Reset to original + return; + } + + if (name.trim() === sessionName) { + setIsEditing(false); + return; + } + + setIsLoading(true); + updateSessionMetadata(sessionId, name.trim()) + .then(() => { + setIsEditing(false); + onNameUpdated?.(name.trim()); + toastSuccess({ title: 'Session name updated' }); + }) + .catch((error) => { + console.error('Failed to update session name:', error); + if (error instanceof Error && error.message.includes('400')) { + toastError({ + title: `Session name too long (max ${MAX_DESCRIPTION_LENGTH} characters)`, + }); + } else { + toastError({ title: 'Failed to update session name' }); + } + setName(sessionName); // Reset to original + }) + .finally(() => { + setIsLoading(false); + }); + } + }; + + // Add listener with slight delay to prevent immediate triggering + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 100); + + return () => { + window.clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isEditing, sessionId, name, sessionName, onNameUpdated]); + + const handleSave = async () => { + if (name.trim() === '') { + toastError({ title: 'Session name cannot be empty' }); + setName(sessionName); // Reset to original + return; + } + + if (name.trim().length > MAX_DESCRIPTION_LENGTH) { + toastError({ title: `Session name too long (max ${MAX_DESCRIPTION_LENGTH} characters)` }); + setName(sessionName); // Reset to original + return; + } + + if (name.trim() === sessionName) { + setIsEditing(false); + return; + } + + setIsLoading(true); + try { + await updateSessionMetadata(sessionId, name.trim()); + setIsEditing(false); + onNameUpdated?.(name.trim()); + toastSuccess({ title: 'Session name updated' }); + } catch (error) { + console.error('Failed to update session name:', error); + if (error instanceof Error && error.message.includes('400')) { + toastError({ title: `Session name too long (max ${MAX_DESCRIPTION_LENGTH} characters)` }); + } else { + toastError({ title: 'Failed to update session name' }); + } + setName(sessionName); // Reset to original + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + setName(sessionName); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }; + + const handleStartEditing = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsEditing(true); + }; + + return ( +
+ {isEditing ? ( +
+ + setName(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isLoading} + className="bg-transparent outline-none w-full min-w-[150px] max-w-[250px] text-textStandard disabled:opacity-50" + placeholder={`Enter session name (max ${MAX_DESCRIPTION_LENGTH} chars)`} + maxLength={MAX_DESCRIPTION_LENGTH} + style={{ + WebkitAppRegion: 'no-drag', + pointerEvents: 'auto', + position: 'relative', + zIndex: 102, + }} + /> +
+ ) : ( + + )} +
+ ); +} diff --git a/ui/desktop/src/hooks/useSessionMetadata.ts b/ui/desktop/src/hooks/useSessionMetadata.ts new file mode 100644 index 000000000000..7e560f038090 --- /dev/null +++ b/ui/desktop/src/hooks/useSessionMetadata.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback } from 'react'; +import { fetchSessionDetails } from '../sessions'; + +interface UseSessionMetadataReturn { + sessionName: string | null; + isSessionNameSet: boolean; + refreshSessionName: () => Promise; + updateSessionName: (newName: string) => void; +} + +/** + * Custom hook to manage session metadata + * Only shows session name if it's been set (not just the session ID) + */ +export function useSessionMetadata(sessionId: string): UseSessionMetadataReturn { + const [sessionName, setSessionName] = useState(null); + const [isSessionNameSet, setIsSessionNameSet] = useState(false); + + const refreshSessionName = useCallback(async () => { + if (!sessionId) return; + + try { + const sessionDetails = await fetchSessionDetails(sessionId); + const description = sessionDetails.metadata.description; + + // Only set the session name if it's different from the session ID + // This indicates it's been auto-generated or user-set, not just the default ID + if (description && description !== sessionId) { + setSessionName(description); + setIsSessionNameSet(true); + } else { + setSessionName(null); + setIsSessionNameSet(false); + } + } catch (error) { + console.error('Error fetching session metadata:', error); + setSessionName(null); + setIsSessionNameSet(false); + } + }, [sessionId]); + + const updateSessionName = (newName: string) => { + setSessionName(newName); + setIsSessionNameSet(true); + }; + + // Initial fetch + useEffect(() => { + refreshSessionName(); + }, [sessionId, refreshSessionName]); + + return { + sessionName, + isSessionNameSet, + refreshSessionName, + updateSessionName, + }; +} diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 2ebb1e5dedf1..fbfa77663faa 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, getSecretKey } from './config'; export interface SessionMetadata { description: string; @@ -126,3 +127,32 @@ export async function fetchSessionDetails(sessionId: string): Promise { + try { + const url = getApiUrl(`/sessions/${sessionId}/metadata`); + + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': getSecretKey(), + }, + 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; + } +}